Skip to content
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

Closed
gili-labs opened this issue May 8, 2021 · 22 comments
Closed

Inspecting Custom View Modifiers Issue #108

gili-labs opened this issue May 8, 2021 · 22 comments

Comments

@gili-labs
Copy link

Consider the following ViewModifier:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
private struct InspectableTestModifier: ViewModifier, Inspectable {
    
    @Binding var flag: Bool
    
    var didAppear: ((Self.Body) -> Void)?
    
    func body(content: Self.Content) -> some View {
        HStack {
            content
            Button(
                action: { self.flag.toggle() },
                label: { Text(flag ? "true" : "false") }
            )
        }
        .onAppear { self.didAppear?(self.body(content: content)) }
    }
}

And the following test:

    func testViewModifierOnFunction() throws {
        @Binding var flag = false
        var sut = InspectableTestModifier(flag: $flag)
        let exp = sut.on(\.didAppear) { body in
            let text1 = try body.find(relation: .child, traversal: .breadthFirst, skipFound: 0) {
                try $0.text().string() == "false"
            }
            let text2 = try body.find(relation: .child, traversal: .breadthFirst, skipFound: 1) {
                try $0.text().string() == "false"
            }
            
            var label1 = try body.hStack()
                .button(1).labelView().text().string()
            var label2 = try body.hStack().modifier(InspectableTestModifier.self).hStack()
                .button(1).labelView().text().string()
            print("\(text1.pathToRoot) = \(label1)")
            print("\(text2.pathToRoot) = \(label2)")
            
            try body.hStack().button(1).tap()
            
            label1 = try body.hStack()
                .button(1).labelView().text().string()
            label2 = try body.hStack().modifier(InspectableTestModifier.self).hStack()
                .button(1).labelView().text().string()
            print("\(text1.pathToRoot) = \(label1)")
            print("\(text2.pathToRoot) = \(label2)")
        }
        ViewHosting.host(view: Circle().modifier(sut))
        wait(for: [exp], timeout: 3.2)
    }

Here's a result of running the test:

Test Suite 'Selected tests' started at 2021-05-08 15:58:43.465
Test Suite 'ViewInspectorTests.xctest' started at 2021-05-08 15:58:43.466
Test Suite 'InspectionEmissaryTests' started at 2021-05-08 15:58:43.466
Test Case '-[ViewInspectorTests.InspectionEmissaryTests testViewModifierOnFunction]' started.
hStack().button(1).labelView().text() = false
hStack().modifier(InspectableTestModifier.self).hStack().button(1).labelView().text() = false
hStack().button(1).labelView().text() = false
hStack().modifier(InspectableTestModifier.self).hStack().button(1).labelView().text() = true
Test Case '-[ViewInspectorTests.InspectionEmissaryTests testViewModifierOnFunction]' passed (0.177 seconds).
Test Suite 'InspectionEmissaryTests' passed at 2021-05-08 15:58:43.644.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.177 (0.178) seconds
Test Suite 'ViewInspectorTests.xctest' passed at 2021-05-08 15:58:43.644.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.177 (0.179) seconds
Test Suite 'Selected tests' passed at 2021-05-08 15:58:43.645.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.177 (0.180) seconds
Program ended with exit code: 0

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?

@gili-labs
Copy link
Author

gili-labs commented May 14, 2021

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 HStack contained by InspectableTestModifier is modified, but not by an InspectableTestModifier (wouldn't this infinitely recurse?), rather it is modified by .onAppear. However, I backed up to look at the inspected HStack. I inserted the following into the test:

        let exp = sut.on(\.didAppear) { body in
            let hStack = body.hStack()
            ...
        }

I set a breakpoint after this and inspected hStack. Here's a partial dump:

(lldb) po Inspector.print(hStack) as AnyObject
InspectableView<HStack>
  content: Content
    medium: Medium
      environmentModifiers: Array<EnvironmentModifier> = []
      environmentObjects: Array<AnyObject> = []
      viewModifiers: Array<Any>
        [0]
          content: EmptyView = EmptyView()
          modifier: InspectableTestModifier
            _flag: Binding<Bool>
              _value: Bool = false
              location: LocationBox<FunctionalLocation<Bool>>
                _cache: UnsafeLockedPointer<LocationProjectionCache>
                  base: LockedPointer
                    rawValue: UnsafeMutablePointer<LockedPointerData>
                      pointerValue: UInt64 = 4329635776
                location: FunctionalLocation<Bool>
                  getValue: () -> Bool = (Function)
                  setValue: (Bool, Transaction) -> () = (Function)
              transaction: Transaction
                plist: PropertyList
                  elements: Optional<Element> = nil
            body: ModifiedContent<HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>, _AppearanceActionModifier>
              content: HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
                _tree: Tree<_HStackLayout, TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
                  content: TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>
                    value: (_ViewModifier_Content<InspectableTestModifier>, Button<Text>)
                      .0: _ViewModifier_Content<InspectableTestModifier> = _ViewModifier_Content<InspectableTestModifier>()
                      .1: Button<Text>
                        _label: Text
                          modifiers: Array<Modifier> = []
                          storage: Storage
                            anyTextStorage: LocalizedTextStorage
                              bundle: Optional<NSBundle> = nil
                              key: LocalizedStringKey
                                arguments: Array<FormatArgument> = []
                                hasFormatting: Bool = false
                                key: String = false
                              table: Optional<String> = nil
                        action: () -> () = (Function)
                  root: _HStackLayout
                    alignment: VerticalAlignment
                      key: AlignmentKey
                        bits: UInt = 140735382543329
                    spacing: Optional<CGFloat> = nil
              modifier: _AppearanceActionModifier
                appear: Optional<() -> ()>
                  some: () -> () = (Function)
                disappear: Optional<() -> ()> = nil
            didAppear: Optional<(ModifiedContent<HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>, _AppearanceActionModifier>) -> ()>
              some: (ModifiedContent<HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>, _AppearanceActionModifier>) -> () = (Function)
        [1]
          content: HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
            _tree: Tree<_HStackLayout, TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
              content: TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>
                value: (_ViewModifier_Content<InspectableTestModifier>, Button<Text>)
                  .0: _ViewModifier_Content<InspectableTestModifier> = _ViewModifier_Content<InspectableTestModifier>()
                  .1: Button<Text>
                    _label: Text
                      modifiers: Array<Modifier> = []
                      storage: Storage
                        anyTextStorage: LocalizedTextStorage
                          bundle: Optional<NSBundle> = nil
                          key: LocalizedStringKey
                            arguments: Array<FormatArgument> = []
                            hasFormatting: Bool = false
                            key: String = false
                          table: Optional<String> = nil
                    action: () -> () = (Function)
              root: _HStackLayout
                alignment: VerticalAlignment
                  key: AlignmentKey
                    bits: UInt = 140735382543329
                spacing: Optional<CGFloat> = nil
          modifier: _AppearanceActionModifier
            appear: Optional<() -> ()>
              some: () -> () = (Function)
            disappear: Optional<() -> ()> = nil
    view: HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
      _tree: Tree<_HStackLayout, TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
        content: TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>
          value: (_ViewModifier_Content<InspectableTestModifier>, Button<Text>)
            .0: _ViewModifier_Content<InspectableTestModifier> = _ViewModifier_Content<InspectableTestModifier>()
            .1: Button<Text>
              _label: Text
                modifiers: Array<Modifier> = []
                storage: Storage
                  anyTextStorage: LocalizedTextStorage
                    bundle: Optional<NSBundle> = nil
                    key: LocalizedStringKey
                      arguments: Array<FormatArgument> = []
                      hasFormatting: Bool = false
                      key: String = false
                    table: Optional<String> = nil
              action: () -> () = (Function)
        root: _HStackLayout
          alignment: VerticalAlignment
            key: AlignmentKey
              bits: UInt = 140735382543329
          spacing: Optional<CGFloat> = nil

Why does content.medium.viewModifiers have two view modifiers? The first view modifier makes sense to me, but the second one does not.

@gili-labs
Copy link
Author

I have a theory. Let me start with what lead me to the theory. In the same debug session, I dumped body and here's what I see:

(lldb) po Inspector.print(view) as AnyObject
InspectableView<ClassifiedView>
  content: Content
    medium: Medium
      environmentModifiers: Array<EnvironmentModifier> = []
      environmentObjects: Array<AnyObject> = []
      viewModifiers: Array<Any>
        [0]
          content: EmptyView = EmptyView()
          modifier: InspectableTestModifier
            _flag: Binding<Bool>
              _value: Bool = false
              location: LocationBox<FunctionalLocation<Bool>>
                _cache: UnsafeLockedPointer<LocationProjectionCache>
                  base: LockedPointer
                    rawValue: UnsafeMutablePointer<LockedPointerData>
                      pointerValue: UInt64 = 4329635776
                location: FunctionalLocation<Bool>
                  getValue: () -> Bool = (Function)
                  setValue: (Bool, Transaction) -> () = (Function)
              transaction: Transaction
                plist: PropertyList
                  elements: Optional<Element> = nil
            body: ModifiedContent<HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>, _AppearanceActionModifier>
              content: HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
                _tree: Tree<_HStackLayout, TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
                  content: TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>
                    value: (_ViewModifier_Content<InspectableTestModifier>, Button<Text>)
                      .0: _ViewModifier_Content<InspectableTestModifier> = _ViewModifier_Content<InspectableTestModifier>()
                      .1: Button<Text>
                        _label: Text
                          modifiers: Array<Modifier> = []
                          storage: Storage
                            anyTextStorage: LocalizedTextStorage
                              bundle: Optional<NSBundle> = nil
                              key: LocalizedStringKey
                                arguments: Array<FormatArgument> = []
                                hasFormatting: Bool = false
                                key: String = false
                              table: Optional<String> = nil
                        action: () -> () = (Function)
                  root: _HStackLayout
                    alignment: VerticalAlignment
                      key: AlignmentKey
                        bits: UInt = 140735382543329
                    spacing: Optional<CGFloat> = nil
              modifier: _AppearanceActionModifier
                appear: Optional<() -> ()>
                  some: () -> () = (Function)
                disappear: Optional<() -> ()> = nil
            didAppear: Optional<(ModifiedContent<HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>, _AppearanceActionModifier>) -> ()>
              some: (ModifiedContent<HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>, _AppearanceActionModifier>) -> () = (Function)
        [1]
          content: HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
            _tree: Tree<_HStackLayout, TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
              content: TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>
                value: (_ViewModifier_Content<InspectableTestModifier>, Button<Text>)
                  .0: _ViewModifier_Content<InspectableTestModifier> = _ViewModifier_Content<InspectableTestModifier>()
                  .1: Button<Text>
                    _label: Text
                      modifiers: Array<Modifier> = []
                      storage: Storage
                        anyTextStorage: LocalizedTextStorage
                          bundle: Optional<NSBundle> = nil
                          key: LocalizedStringKey
                            arguments: Array<FormatArgument> = []
                            hasFormatting: Bool = false
                            key: String = false
                          table: Optional<String> = nil
                    action: () -> () = (Function)
              root: _HStackLayout
                alignment: VerticalAlignment
                  key: AlignmentKey
                    bits: UInt = 140735382543329
                spacing: Optional<CGFloat> = nil
          modifier: _AppearanceActionModifier
            appear: Optional<() -> ()>
              some: () -> () = (Function)
            disappear: Optional<() -> ()> = nil
    view: HStack<TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
      _tree: Tree<_HStackLayout, TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>>
        content: TupleView<(_ViewModifier_Content<InspectableTestModifier>, Button<Text>)>
          value: (_ViewModifier_Content<InspectableTestModifier>, Button<Text>)
            .0: _ViewModifier_Content<InspectableTestModifier> = _ViewModifier_Content<InspectableTestModifier>()
            .1: Button<Text>
              _label: Text
                modifiers: Array<Modifier> = []
                storage: Storage
                  anyTextStorage: LocalizedTextStorage
                    bundle: Optional<NSBundle> = nil
                    key: LocalizedStringKey
                      arguments: Array<FormatArgument> = []
                      hasFormatting: Bool = false
                      key: String = false
                    table: Optional<String> = nil
              action: () -> () = (Function)
        root: _HStackLayout
          alignment: VerticalAlignment
            key: AlignmentKey
              bits: UInt = 140735382543329
          spacing: Optional<CGFloat> = nil
  inspectionCall: String = 
  inspectionIndex: Optional<Int> = nil
  parentView: Optional<UnwrappedView> = nil

I checked out the documentation:

View Modifiers
Configure this view and the views it contains by applying standard modifiers.

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)

@gili-labs
Copy link
Author

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.

@nalexn
Copy link
Owner

nalexn commented May 14, 2021

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 body to the inspection code the library has to unwrap the view, and it erroneously picks up the parent's Medium, which contains the .modifier(InspectableTestModifier.self). This modifier gets inherited by HStack thus creating this phantom secondary hierarchy.

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 .onAppear { self.didAppear?(self.body(content: content)) } creates a surrogate that is not hosted thus disconnected from the values store - changes to the state don't take effect because of this.

I know I proposed that snippet .onAppear { self.didAppear?(self.body(content: content)) }, but it's not fully correct due to the problem #2. It was rather a quick way to make ViewModifiers async-testable, but I really didn't investigate the possible consequences and edge cases like you've found.

Support for custom ViewModifiers requires further development, specifically I want the inner hierarchy to inherit the applied modifiers, like in the case of this custom modifier:

func body(content: Content) -> some View {
    content
        .offset()
        .environmentObject(obj)
}

I'd expect the view, to which this modifier is applied, to inherit the EnvironmentObject as well as the correct offset. This does not happen currently.

@gili-labs
Copy link
Author

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 ViewModifier.on and the test failed, which led me to this problem.

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.

@gili-labs
Copy link
Author

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.

@gili-labs
Copy link
Author

I'm starting to think the best way to test view modifiers is in the context of a test view.

@nalexn
Copy link
Owner

nalexn commented May 17, 2021

@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

@gili-labs
Copy link
Author

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.

@gili-labs
Copy link
Author

I know I proposed that snippet .onAppear { self.didAppear?(self.body(content: content)) }, but it's not fully correct due to the problem #2. It was rather a quick way to make ViewModifiers async-testable, but I really didn't investigate the possible consequences and edge cases like you've found.

Support for custom ViewModifiers requires further development, specifically I want the inner hierarchy to inherit the applied modifiers, like in the case of this custom modifier:

func body(content: Content) -> some View {
    content
        .offset()
        .environmentObject(obj)
}

I'd expect the view, to which this modifier is applied, to inherit the EnvironmentObject as well as the correct offset. This does not happen currently.

@nalexn: I just tested the changes I made with offset(_:) and it works fine. I'm going to try environmentModifier(_:) and see if this fixes this as well.

@nalexn
Copy link
Owner

nalexn commented May 31, 2021

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!

@gili-labs
Copy link
Author

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.

@nalexn
Copy link
Owner

nalexn commented May 31, 2021

You're doing an incredible job, thank you again!

@gili-labs
Copy link
Author

@nalexn: Do you have an example of this:

func body(content: Content) -> some View {
    content
        .offset()
        .environmentObject(obj)
}

Where does body(content:) get obj from?

@nalexn
Copy link
Owner

nalexn commented May 31, 2021

Here is an example.

Actually, the implementation of this feature might be quite straightforward.
There is a view type that represents the content placeholder view in the ViewModifier: ViewType.ViewModifierContent.

So when the library unwraps a view and stumbles upon a custom ViewModifier, it should inspect it and do find(ViewType.ViewModifierContent.self). The resulting view's Medium will contain all the applied modifiers and env objects, so the content of the Medium should just get appended to the view that was being unwrapped.

You can start with a simple case like

func body(content: Content) -> some View {
    content
        .offset()
}

It's possible that ViewModifier does not contain the content, and simply returns an alternative hierarchy. AN edge case, but would be interesting to crack it as well

@nalexn
Copy link
Owner

nalexn commented May 31, 2021

Hm, just noticed that the example I sent you doesn't really show the case. The env object could be supplied in the init of the ViewModifier and stored in a local var.

@gili-labs
Copy link
Author

Have to ask what viewModifierContent() returns? Is this the actual content passed into body(content:)? If so, why can't I inspect accordingly?

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 content would be a Text("Monkey Wrench"), and hence, the test should have access to the its value using the string() method. However, the compiler complains Referencing instance method 'string(locale:)' on 'InspectableView' requires the types 'ViewType.ViewModifierContent' and 'ViewType.Text' be equivalent.

    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 content.padding() and it will return 10.

@nalexn
Copy link
Owner

nalexn commented Jun 1, 2021

Hey, you cannot assume the content to be a specific view, like Text. It's a placeholder. In runtime, it remains a placeholder that tells SwiftUI: render in here the view to which the modifier is applied.
Thus, you cannot really inspect that placeholder, it's a stub with no meaningful data. But we can follow the same logic as SwiftUI's render engine and logically substitute there the outer view there, if needed.

What I envisioned for the subject is to unwrap that placeholder just to collect all the applied modifiers inside the ViewModifier, like padding(). The placeholder view after that is useless, but the Medium will contain all the values we need to apply to the outer view, which might be a Text.

So when you unwrap the text from Text("abc").modifier(CustomModifier()) the text would magically obtain the padding modifier, which is applied inside the CustomModifier

@gili-labs
Copy link
Author

I think we need a method that will allow us to fetch an environment object "attached" to the content, much like the environment method.

@nalexn
Copy link
Owner

nalexn commented Jun 1, 2021

If there is an attached environment objects to the content, it will also be collected in the Medium as you do find that content view.

@gili-labs
Copy link
Author

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.

@nalexn
Copy link
Owner

nalexn commented Jun 15, 2021

This is resolved now in v0.8.0

@nalexn nalexn closed this as completed Jun 15, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants