New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Inspecting Custom View Modifiers Issue #108
Comments
I'm still drilling down on this problem. At this point, I think the fact that I can do this is central to the problem: try body.hStack().modifier(InspectableTestModifier.self) The let exp = sut.on(\.didAppear) { body in
let hStack = body.hStack()
...
} I set a breakpoint after this and inspected
Why does |
I have a theory. Let me start with what lead me to the theory. In the same debug session, I dumped
I checked out the documentation:
I'm fairly certain this is why I can do do this: try body.hStack().modifier(InspectableTestModifier.self) I'm going to check it out, but I'm fairly certain that I can do this too: try body.hStack().button(1).modifier(InspectableTestModifier.self) |
Tried this and it fails: try body.hStack().button(1).modifier(InspectableTestModifier.self) On some level I think this is a bug. However, I can't quite put my finger on it. |
Hey @gili-labs That's an interesting case. After debugging this a bit I consider this a bug in the library (actually two). Before returning the The second problem is that the secondary hierarchy is actually the correct one that should be used (as you can see it turns the flag "true" after tapping the button), because it is the original structure that was hosted on screen by SwiftUI and connected to the SwiftUI's internal values store. The I know I proposed that snippet Support for custom func body(content: Content) -> some View {
content
.offset()
.environmentObject(obj)
} I'd expect the |
There is one data point I neglected to share with you. This all started when I attempted to add support to InspectorEmissary for view modifiers. I completed this work and tested it successfully. However, I refactored the test for Here is the entire InspectorEmissary file: import SwiftUI
import Combine
import XCTest
// MARK: - InspectionEmissaryBase
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
public protocol InspectionEmissaryBase: AnyObject {
associatedtype V
associatedtype Body
associatedtype InspectedBody
typealias Inspection = (InspectedBody) throws -> Void
var notice: PassthroughSubject<UInt, Never> { get }
var callbacks: [UInt: (Body) -> Void] { get set }
@discardableResult
func inspect(after delay: TimeInterval,
function: String, file: StaticString, line: UInt,
_ inspection: @escaping Inspection
) -> XCTestExpectation
@discardableResult
func inspect<P>(onReceive publisher: P,
function: String, file: StaticString, line: UInt,
_ inspection: @escaping Inspection
) -> XCTestExpectation where P: Publisher, P.Failure == Never
func setup(inspection: @escaping (InspectedBody) throws -> Void,
expectation: XCTestExpectation,
function: String, file: StaticString, line: UInt)
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
public extension InspectionEmissaryBase {
@discardableResult
func inspect(after delay: TimeInterval = 0,
function: String = #function, file: StaticString = #file, line: UInt = #line,
_ inspection: @escaping Inspection
) -> XCTestExpectation {
let exp = XCTestExpectation(description: "Inspection at line \(line)")
setup(inspection: inspection, expectation: exp, function: function, file: file, line: line)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak notice] in
notice?.send(line)
}
return exp
}
@discardableResult
func inspect<P>(onReceive publisher: P,
function: String = #function, file: StaticString = #file, line: UInt = #line,
_ inspection: @escaping Inspection
) -> XCTestExpectation where P: Publisher, P.Failure == Never {
let exp = XCTestExpectation(description: "Inspection at line \(line)")
setup(inspection: inspection, expectation: exp, function: function, file: file, line: line)
var subscription: AnyCancellable?
_ = subscription
subscription = publisher.sink { [weak notice] _ in
subscription = nil
DispatchQueue.main.async {
notice?.send(line)
}
}
return exp
}
}
// MARK: - InspectionEmissary for Inspectable Views
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
public protocol InspectionEmissary: InspectionEmissaryBase where
V: View & Inspectable, Body == V, InspectedBody == InspectableView<ViewType.View<V>> {
}
extension InspectionEmissary {
func setup(inspection: @escaping Inspection,
expectation: XCTestExpectation,
function: String, file: StaticString, line: UInt) {
callbacks[line] = { [weak self] view in
do {
try inspection(try view.inspect(function: function))
} catch let error {
XCTFail("\(error.localizedDescription)", file: file, line: line)
}
if self?.callbacks.count == 0 {
ViewHosting.expel(function: function)
}
expectation.fulfill()
}
}
}
// MARK: - InspectionEmissary for View Modifiers
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
public protocol InspectionEmissaryForViewModifier: InspectionEmissaryBase where
V: ViewModifier & Inspectable, Body == V.Body, InspectedBody == InspectableView<ViewType.ClassifiedView> {
}
extension InspectionEmissaryForViewModifier {
func setup(inspection: @escaping Inspection,
expectation: XCTestExpectation,
function: String, file: StaticString, line: UInt) {
callbacks[line] = { [weak self] view in
do {
try inspection(try view.inspect(function: function))
} catch let error {
XCTFail("\(error.localizedDescription)", file: file, line: line)
}
if self?.callbacks.count == 0 {
ViewHosting.expel(function: function)
}
expectation.fulfill()
}
}
}
// MARK: - On Functions
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
public extension View where Self: Inspectable {
@discardableResult
mutating func on(_ keyPath: WritableKeyPath<Self, ((Self) -> Void)?>,
function: String = #function, file: StaticString = #file, line: UInt = #line,
perform: @escaping ((InspectableView<ViewType.View<Self>>) throws -> Void)
) -> XCTestExpectation {
let description = Inspector.typeName(value: self) + " callback at line #\(line)"
let expectation = XCTestExpectation(description: description)
self[keyPath: keyPath] = { view in
view.inspect(function: function, file: file, line: line, inspection: perform)
ViewHosting.expel(function: function)
expectation.fulfill()
}
return expectation
}
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
public extension ViewModifier where Self: Inspectable {
@discardableResult
mutating func on(_ keyPath: WritableKeyPath<Self, ((Self.Body) -> Void)?>,
function: String = #function, file: StaticString = #file, line: UInt = #line,
perform: @escaping ((InspectableView<ViewType.ClassifiedView>) throws -> Void)
) -> XCTestExpectation {
let description = Inspector.typeName(value: self) + " callback at line #\(line)"
let expectation = XCTestExpectation(description: description)
self[keyPath: keyPath] = { body in
body.inspect(function: function, file: file, line: line, inspection: perform)
ViewHosting.expel(function: function)
expectation.fulfill()
}
return expectation
}
} Here is the test that asynchronously inspects a view modifier. Note, this test passes: @available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
final class InspectionEmissaryTests: XCTestCase {
...
func testViewModifierInspectAfter() throws {
let flag = Binding<Bool>(wrappedValue: false)
let sut = InspectableTestModifier(flag: flag)
let exp1 = sut.inspection.inspect { view in
let text = try view.hStack().button(1).labelView().text().string()
XCTAssertEqual(text, "false")
sut.publisher.send(true)
}
let exp2 = sut.inspection.inspect(after: 0.1) { view in
let text = try view.hStack().button(1).labelView().text().string()
XCTAssertEqual(text, "true")
}
let view = EmptyView().modifier(sut)
ViewHosting.host(view: view)
wait(for: [exp1, exp2], timeout: 0.1)
}
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
class InspectionForViewModifier<V>: InspectionEmissaryForViewModifier where V: ViewModifier & Inspectable {
let notice = PassthroughSubject<UInt, Never>()
var callbacks = [UInt: (V.Body) -> Void]()
func visit(_ body: V.Body, _ line: UInt) {
if let callback = callbacks.removeValue(forKey: line) {
callback(body)
}
}
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
private struct InspectableTestModifier: ViewModifier, Inspectable {
@Binding var flag: Bool
let publisher = PassthroughSubject<Bool, Never>()
let inspection = InspectionForViewModifier<Self>()
var didAppear: ((Self.Body) -> Void)?
func body(content: Self.Content) -> some View {
HStack {
content
Button(
action: { self.flag.toggle() },
label: { Text(flag ? "true" : "false").id("label") }
)
}
.onReceive(publisher) { self.flag = $0 }
.onReceive(inspection.notice) { self.inspection.visit(self.body(content: content), $0) }
.onAppear { self.didAppear?(self.body(content: content)) }
}
} There's not a lot of difference between the two methods. Which leaves me even more puzzled. |
I have created a PR to give you better visibility into what I'm seeing. I'm going to keep trying to drill down on this and figure out what's going on. |
I'm starting to think the best way to test view modifiers is in the context of a test view. |
@gili-labs that might be the case. Sorry for being slow to respond - I'll go through the PR and reply as soon as I get time |
No problem, I continue to play with it. If anything, it gives me a deeper understanding of aspects of the framework that I haven't dug into yet. |
@nalexn: I just tested the changes I made with |
Awesome, thanks @gili-labs ! I'm so jammed with the work, but I'll get to review your PR hopefully by the end of this week! |
No problem @nalexn. I've been banging my head against the wall for weeks trying to decipher this problem. Thankfully, we had a four day weekend and I had the time to tackle this. |
You're doing an incredible job, thank you again! |
@nalexn: Do you have an example of this: func body(content: Content) -> some View {
content
.offset()
.environmentObject(obj)
} Where does |
Here is an example. Actually, the implementation of this feature might be quite straightforward. So when the library unwraps a view and stumbles upon a custom You can start with a simple case like func body(content: Content) -> some View {
content
.offset()
} It's possible that |
Hm, just noticed that the example I sent you doesn't really show the case. The env object could be supplied in the |
Have to ask what Let me provide an example view modifier: private struct InspectableTestModifier: ViewModifier, Inspectable {
typealias Body = BodyView
@Binding var flag: Bool
var didAppear: ((Self.Body) -> Void)?
func body(content: Self.Content) -> BodyView {
BodyView(content: content, flag: $flag, didAppear: didAppear)
}
struct BodyView: View & Inspectable {
let content: InspectableTestModifier.Content
@Binding var flag: Bool
var didAppear: ((Self) -> Void)?
var body: some View {
HStack {
content
Button(
action: { self.flag.toggle() },
label: { Text(flag ? "true" : "false").id("label") }
)
}
.onAppear { self.didAppear?(self) }
}
}
} In the below test, I would think that func testViewModifierOnFunction() throws {
@Binding var flag = false
var sut = InspectableTestModifier(flag: $flag)
let exp = sut.on(\.didAppear) { body in
let content = try body.hStack().viewModifierContent(0)
let text = try content.string()
}
let view = Text("Monkey Wrench").modifier(sut)
ViewHosting.host(view: view)
wait(for: [exp], timeout: 0.2)
} This isn't intuitive to me, because if the view modifier is simpler and only does the following: var body: some View {
content
.padding(10)
} Then the test can access the content's padding through |
Hey, you cannot assume the What I envisioned for the subject is to unwrap that placeholder just to collect all the applied modifiers inside the So when you unwrap the text from |
I think we need a method that will allow us to fetch an environment object "attached" to the content, much like the |
If there is an attached environment objects to the |
Very cool. I just tested this and the attached environment object is there. So, the changed I have committed to this PR should solve our problems with view modifiers. |
This is resolved now in v0.8.0 |
Consider the following ViewModifier:
And the following test:
Here's a result of running the test:
I've been chasing this for a couple weeks and cannot unravel this mystery.
Question #1, do you have any idea why the view generates the second instance of the label?
Question #2, do you have any idea why the second instance of the label has the root-to-path that it has?
Question #3, why does the button tap change the second instance of the label, but not the first?
The text was updated successfully, but these errors were encountered: