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

CustomInspectable view support #288

Merged
merged 3 commits into from Jan 20, 2024

Conversation

bryankeller
Copy link
Contributor

@bryankeller bryankeller commented Jan 6, 2024

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:

  • HorizonCalendar, a UIKit calendar library with full support for being used in SwiftUI view hierarchies (and using SwiftUI views as its actual content)
  • Wrapping Google Maps in a view representable and using SwiftUI views as the annotation views

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 bryankeller force-pushed the bk/custom-inspectable-support branch 2 times, most recently from c844e4f to 80db9d7 Compare January 8, 2024 22:42
@bryankeller bryankeller marked this pull request as ready for review January 8, 2024 22:52
@bryankeller
Copy link
Contributor Author

bryankeller commented Jan 8, 2024

@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
       }
     }
   }
 }

Copy link

@rafael-assis rafael-assis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding this powerful functionality to ViewInspector, @bryankeller!
@nalexn we appreciate your thoughts on the approach we're taking.

@@ -127,7 +139,11 @@ public struct Content {
let medium: Medium

internal init(_ view: Any, medium: Medium = .empty) {
self.view = view
if let customInspectable = view as? any CustomInspectable {
self.view = customInspectable.customInspectableContent

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

/// A protocol for views that need to expose a custom view to `ViewInspector` that differs from its default inspectable
/// representation.
///
/// A use case for this might be a `UIViewRepresentable`that contains some hosted SwiftUI views (consider a wrapped

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@nalexn
Copy link
Owner

nalexn commented Jan 15, 2024

@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
Copy link

@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
Copy link
Contributor Author

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 pushed a commit that referenced this pull request Jan 20, 2024
nalexn pushed a commit that referenced this pull request Jan 20, 2024
@nalexn nalexn merged commit e772575 into nalexn:0.9.10 Jan 20, 2024
@nalexn nalexn added the pending release A fixed issue that'll be released in an upcoming update label Jan 20, 2024
@nalexn
Copy link
Owner

nalexn commented Jan 20, 2024

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

@nalexn nalexn removed the pending release A fixed issue that'll be released in an upcoming update label Jan 21, 2024
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

Successfully merging this pull request may close these issues.

None yet

3 participants