nalexn / ViewInspector

Runtime introspection and unit testing of SwiftUI views
MIT License
2.16k stars 148 forks source link

Accessing properties on a view that takes a generic #294

Open JOyo246 opened 7 months ago

JOyo246 commented 7 months ago

Considering some view that takes in content

struct WrapperView<Content>: View where Content: View {
    let someProperty: Int
    @ViewBuilder var content: () -> Content

    var body: some View {
        VStack {
            Text("Some property \(someProperty / 2)")
            content()
        }
    }
}

let sut = VStack { 
    WrapperView("Cool") {
        VStack {VStack {VStack {VStack { Text("Some Complicated Content") }}}}
    }
}

We can find a view that is nested in content like so:

let foundWrapper = try sut.find(WrapperView<EmptyView>.self) // succeeds
let foundText = try foundWrapper.find(Text.self) // succeeds

But, now it seems as though it's impossible to access someProperty?

let foundWrapperAV = try foundWrapper.actualView().someProperty // fails with type mismatch

Just want to make sure we are not missing anything. we'd like to be able to get the properties of WrapperView, ignoring the Content generic

grantneufeld commented 7 months ago

Would casting the result of actualView() to the expected class work?

E.g., something like:

let wrapperView = try foundWrapper.actualView() as? WrapperView<EmptyView>
let foundWrapperAV = wrapperView?.someProperty

(I’m not in front of Xcode to try this out right now, so the code above might not be exact.)

JOyo246 commented 5 months ago

Would casting the result of actualView() to the expected class work?

E.g., something like:

let wrapperView = try foundWrapper.actualView() as? WrapperView<EmptyView>
let foundWrapperAV = wrapperView?.someProperty

(I’m not in front of Xcode to try this out right now, so the code above might not be exact.)

Late follow up, but this doesn't work. The actualView method still throws with a type mismatch. Which makes sense, as the view that is found is something like WrapperView<VStack<...>>

nalexn commented 3 months ago

This is a tricky one. Accessing someProperty the normal way means the compiler needs to know the exact type of its container (including the inner generics), so without explicit cast to that type it won't let you do .someProperty.

There is a hacky way though. Try the following:

@testable import ViewInspector // add @testable so you could use internal methods of ViewInspector

let foundWrapper = try sut.find(WrapperView<EmptyView>.self)
let value = try Inspector.attribute(path: "content|view|someProperty", value: foundWrapper, type: Int.self)
josh-arnold-1 commented 2 months ago

Hey @nalexn ! I have a similar issue!

Given a custom container, how can we inspect the generic property content? In your example, you are using a concrete type Int.

struct CustomContainer<Content: View>: View {
    let content: Content
    // ...
}

I'm thinking, this should be possible right since VStack and HStack, etc have a very similar definition?

// SwiftUI VStack
struct VStack<Content> : View where Content : View {}

Also, is there anyway you could briefly explain the path: argument and how it works? I'm confused in your example by the content|view prefix?

Thanks so much for your time!

nalexn commented 2 months ago

@josh-arnold-1 the internal function Inspector.attribute(path: ) is just a handy wrapper around Swift reflection. The content|view|someProperty means it'll read property with name content, on that value read the property with name view, then someProperty, and finally cast the resulting value to Int. Usually, this is used for reading private properties of SwiftUI views, but in this example, both content and view are ViewInspector's internal containers, where view references the actual SwiftUI view. If you want to read the property named differently, like in your example, you'd need to change that last path value, making it content|view|content. I doubt you'll be able to provide the type to cast to though, so instead I'd suggest you use find instead, on the parent view, to locate the view passed as Content.

josh-arnold-1 commented 2 months ago

Thanks a lot for the context! How is the body property of types like VStack resolved in this case? Is it following a similar pattern where you call find on the VStack for the specific child view, and then work your way up the tree?

E.g, something like this?

let view = AnyView(HStack { Text("abc") })
let text = try sut.inspect().find(text: "abc")
let hStack = try text.parent().hStack()
let anyView = try text.parent().parent().anyView()

I'm wondering if the logic for VStack, for example, could somehow be generalized so it also works for custom generic containers like in my example?

Like, I'm wondering what the difference is between these types?

struct CustomContainer<Content>: View where Content : View {}
struct VStack<Content> : View where Content : View {}

Thanks!

nalexn commented 2 months ago

How is the body property of types like VStack resolved in this case?

This line.

It also uses Inspector.attribute(path: ), but without a cast. Casting happens later, as you attempt to unwrap the child view.

Like, I'm wondering what the difference is between these types?

struct CustomContainer<Content>: View where Content : View {}
struct VStack<Content> : View where Content : View {}

The difference is that VStack is pre-defined in SDK, its structure is fixed and the same for all. It also only references child views.

Custom view can have arbitrary inner structure, reference child views and arbitrary properties. If it's a property outside SwiftUI hierarchy, like Int property from the original question, the way to access it is described in my original answer. You can as well use Inspector.attribute(path: ) for reaching child view, its' child view, etc., but it's error-prone. That's why I suggest you use find because it takes all the complexity away.

nalexn commented 2 months ago

Alternatively you can introduce a protocol without generics, like

protocol MyCustomContainerView: View {
    var contentView: Any { get }
}

conform to that: struct CustomContainer<Content>: MyCustomContainerView, View { ... }, and then use that protocol in inspection chain instead of .view(CustomContainer.self). Haven't tested, lmk if this works