nalexn / ViewInspector

Runtime introspection and unit testing of SwiftUI views
MIT License
2.09k stars 145 forks source link

CustomInspectable view support #288

Closed bryankeller closed 5 months ago

bryankeller commented 6 months ago

Hey @nalexn ,

At Airbnb, we've recently discovered that ViewInspector doesn't work with some of our new SwiftUI infrastructure. We're having trouble getting a custom UIViewRepresentable that contains SwiftUI subviews to properly work. Internally, we've wrapped UICollectionView in a UIViewRepresentable, and under the hood, it uses UIHostingConfigurations to show SwiftUI views in cells.

The API for this UIViewRepresentable is pretty declarative - we pass it some data, and under the hood, it diffs the data and updates the collection view.

The issue is that ViewInspector doesn't really know how to handle a UIViewRepresentable that contains SwiftUI views. After some discussion internally, we realized that one way around this limitation is to simply expose an alternative SwiftUI-native representation of the UIViewRepresentable to ViewInspector, based on the current set of data. For example, if we have a UIViewRepresentable wrapping a collection view, and its current cell data looks like this:

0: "First row"
1: "Second row"
2: "Third row"

We could expose this to ViewInspector using a custom view-building property:

@ViewBuilder
var inspectableRepresentation: some View {
    CustomCellView("First row")
    CustomCellView("Second row")
    CustomCellView("Third row")
}

Internally, ViewInspector would use this custom representation, rather than the UIViewRepresentable. The inspectableRepresentation view would likely be a ForEach, rather than a hard-coded VStack like in my simple example above.


The full approach taken is to introduce a protocol called CustomInspectable. Any SwiftUI view can conform to this, and if it does, ViewInspector will use the custom view it provides for inspection, rather than treating it as a UIViewRepresentable or whatever type it originally was.

Other use cases might be:

This approach is a purely additive change, and requires very little additional code in the library. I'd love to get your thoughts on it :)

bryankeller commented 6 months ago

@nalexn this is what it looks like in our codebase, to give you a better idea of how this can be used:


 extension CollectionViewRepresentable: CustomInspectable {

  @ViewBuilder
   public var inspectableRepresentation: some View {
     ForEach(sections) { section in
       ForEach(Array(section.supplementaries.enumerated()), id: \.offset) { _, supplementary in
         supplementary._opaqueViewForTesting
       }
       ForEach(section.items, id: \.id) { item in
         item._opaqueViewForTesting
       }
     }
   }
 }
nalexn commented 5 months ago

@bryankeller thanks for the PR! Just to clarify, there is an API for accessing UIViews and UIViewControllers hosted by UIView(Controller)Representable via .actualView().uiView() and actualView().viewController() calls. You can see examples in the tests.

That gives you access to the UIKit views, but I haven't tested if this is possible to further dig up embedded SwiftUI views from UIKit hierarchy. I assume that should be possible by means of UIKit API (recursive traverse of subviews).

Could you confirm this approach doesn't allow you to achieve what you want?

rafael-assis commented 5 months ago

@bryankeller thanks for the PR! Just to clarify, there is an API for accessing UIViews and UIViewControllers hosted by UIView(Controller)Representable via .actualView().uiView() and actualView().viewController() calls. You can see examples in the tests.

That gives you access to the UIKit views, but I haven't tested if this is possible to further dig up embedded SwiftUI views from UIKit hierarchy. I assume that should be possible by means of UIKit API (recursive traverse of subviews).

Could you confirm this approach doesn't allow you to achieve what you want?

Hi @nalexn,

I can provide some additional context here. @bryankeller please feel free to add more info if you spot anything I missed.

Our internal use case is that we need to use UIKit types to accomplish things that SwiftUI still falls short of our requirements (layout/scrolling capabilities, performance, etc).

The key internal implementation detail is that we use the UICollectionViewCell's contentConfiguration property set to an instance of the UIHostingConfiguration to host SwiftUI views in a UICollectionView.

We basically have a SwiftUI View that wraps a UICollectionView which displays cells containing SwiftUI Views.

The key motivation behind the CustomInspectable is that we don't want to require the testing code to have knowledge of this key internal implementation detail and how the hierarchy is implemented.

It's just more ergonomic and transparent for the testing code if the parent SwiftUI View to expose the children SwiftUI Views in the cells as its content directly (instead of using UIViewRepresentable/UICollectionView) so that the full hierarchy can be easily inspected and traversed in the context of a test using ViewInspector.

We also have some other SwiftUI Views that implement the same "SwiftUI -> UIKit -> SwiftUI" wrapping pattern that are just implemented differently from the key implementation detail I just mentioned above. So the CustomInspectable is a good solution to normalize these scenarios of "SwiftUI -> UIKit -> SwiftUI" for the tests even if their internal implementation is different.

bryankeller commented 5 months ago

Thanks @rafael-assis - that description is spot on.

One thing I'd add is that for our use case, we could technically get the underlying collection view via actualView() and look at its subviews, but then people writing tests would need to know about the internal implementation details of the UIViewRepresentable in order to figure out where the SwiftUI views are (they're in the cell's contentConfigurations via UIHostingConfiguration). With the approach in this PR, folks can write their tests the same way they'd write them for a List (which means swapping the implementation from List to the UIViewRepresentable UICollectionView wouldn't require rewriting the tests).

nalexn commented 5 months ago

Thanks for elaborating on the topic, I've added this use case to the documentation and merged the PR.