From bb59fcc0a7ad08d657226809dc97df5c1c4a2371 Mon Sep 17 00:00:00 2001 From: Bryn Bodayle Date: Thu, 21 Sep 2023 18:52:36 -0700 Subject: [PATCH] [SwiftUI] Adds forcesEarlySwiftUIRendering flag to fix SwiftUI Cell Sizing (#153) * [SwiftUI] Fix reused UIHostingController sizing * Added to collection view configuration --------- Co-authored-by: michael gofron --- CHANGELOG.md | 3 ++ .../CollectionView/CollectionView.swift | 2 ++ .../CollectionViewConfiguration.swift | 10 +++++- .../ReusableViews/CollectionViewCell.swift | 17 +++++++++- .../CollectionViewReusableView.swift | 20 ++++++++++- .../SwiftUI/EpoxySwiftUIHostingView.swift | 34 +++++++++++++------ 6 files changed, 72 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5f21f9..a19fde13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 batch update. - Added caching for `visibilityMetadata` calculations. - Fixed an issue that could cause SwiftUI views to be incorrectly sized in a collection view. +- Added `forcesEarlySwiftUIRendering` flag to `CollectionViewConfiguration` to test a SwiftUI layout + approach to resolve an issue that could cause collection view cells to layout with + unexpected dimensions ## [0.10.0](https://github.com/airbnb/epoxy-ios/compare/0.9.0...0.10.0) - 2023-06-29 diff --git a/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift b/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift index 0f9a67ff..a9f728ab 100644 --- a/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift +++ b/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift @@ -445,6 +445,7 @@ open class CollectionView: UICollectionView { } cell.itemPath = itemPath + cell.forcesEarlySwiftUIRendering = configuration.forcesEarlySwiftUIRendering let metadata = ItemCellMetadata( traitCollection: traitCollection, @@ -469,6 +470,7 @@ open class CollectionView: UICollectionView { animated: Bool) { supplementaryView.itemPath = itemPath + supplementaryView.forcesEarlySwiftUIRendering = configuration.forcesEarlySwiftUIRendering model.configure( reusableView: supplementaryView, traitCollection: traitCollection, diff --git a/Sources/EpoxyCollectionView/CollectionView/CollectionViewConfiguration.swift b/Sources/EpoxyCollectionView/CollectionView/CollectionViewConfiguration.swift index c721ea4e..0eccd871 100644 --- a/Sources/EpoxyCollectionView/CollectionView/CollectionViewConfiguration.swift +++ b/Sources/EpoxyCollectionView/CollectionView/CollectionViewConfiguration.swift @@ -13,11 +13,13 @@ public struct CollectionViewConfiguration { public init( usesBatchUpdatesForAllReloads: Bool = true, usesCellPrefetching: Bool = true, - usesAccurateScrollToItem: Bool = true) + usesAccurateScrollToItem: Bool = true, + forcesEarlySwiftUIRendering: Bool = true) { self.usesBatchUpdatesForAllReloads = usesBatchUpdatesForAllReloads self.usesCellPrefetching = usesCellPrefetching self.usesAccurateScrollToItem = usesAccurateScrollToItem + self.forcesEarlySwiftUIRendering = forcesEarlySwiftUIRendering } // MARK: Public @@ -66,4 +68,10 @@ public struct CollectionViewConfiguration { /// /// - SeeAlso: `CollectionViewScrollToItemHelper` public var usesAccurateScrollToItem: Bool + + /// Flag to use a semi-private API to force an early render of a SwiftUI view embedded in a `UIHostingController`. This is used + /// to synchronously resize after updating the `rootView`. When disabled, layout is forced using standard UIKit functions, a newer + /// approach which is being tested for viability and to resolve issues where SwiftUI views in collection view cells undergo a layout pass + /// with unexpected dimensions. + public var forcesEarlySwiftUIRendering: Bool } diff --git a/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewCell.swift b/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewCell.swift index df49ca90..c34f60b4 100644 --- a/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewCell.swift +++ b/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewCell.swift @@ -7,7 +7,7 @@ import UIKit // MARK: - CollectionViewCell /// An internal cell class for use in a `CollectionView`. -public final class CollectionViewCell: UICollectionViewCell, ItemCellView { +public final class CollectionViewCell: UICollectionViewCell, ItemCellView, SwiftUIRenderingConfigurable { // MARK: Lifecycle @@ -27,6 +27,13 @@ public final class CollectionViewCell: UICollectionViewCell, ItemCellView { public var selectedBackgroundColor: UIColor? + /// See `CollectionViewConfiguration.forcesEarlySwiftUIRendering` for an explanation of this behavior. + public var forcesEarlySwiftUIRendering = true { + didSet { + updateForcesEarlySwiftUIRendering() + } + } + override public var isSelected: Bool { didSet { updateVisualHighlightState(isSelected) @@ -59,6 +66,8 @@ public final class CollectionViewCell: UICollectionViewCell, ItemCellView { view.topAnchor.constraint(equalTo: contentView.topAnchor), view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) + + updateForcesEarlySwiftUIRendering() } override public func preferredLayoutAttributesFitting( @@ -152,6 +161,12 @@ public final class CollectionViewCell: UICollectionViewCell, ItemCellView { } } + private func updateForcesEarlySwiftUIRendering() { + if let configurableView = view as? SwiftUIRenderingConfigurable { + configurableView.forcesEarlySwiftUIRendering = forcesEarlySwiftUIRendering + } + } + } // MARK: EphemeralCachedStateView diff --git a/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift b/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift index 585204a3..3ac74685 100644 --- a/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift +++ b/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift @@ -1,10 +1,11 @@ // Created by Laura Skelton on 9/8/17. // Copyright © 2017 Airbnb. All rights reserved. +import EpoxyCore import UIKit /// An internal collection reusable view class for use in a `CollectionView`. -public final class CollectionViewReusableView: UICollectionReusableView { +public final class CollectionViewReusableView: UICollectionReusableView, SwiftUIRenderingConfigurable { // MARK: Lifecycle @@ -22,6 +23,13 @@ public final class CollectionViewReusableView: UICollectionReusableView { public private(set) var view: UIView? + /// See `CollectionViewConfiguration.forcesEarlySwiftUIRendering` for an explanation of this behavior. + public var forcesEarlySwiftUIRendering = false { + didSet { + updateForcesEarlySwiftUIRendering() + } + } + /// Pass a view for this view's element kind and reuseID that the view will pin to its edges. public func setViewIfNeeded(view: UIView) { if self.view != nil { @@ -39,6 +47,8 @@ public final class CollectionViewReusableView: UICollectionReusableView { view.bottomAnchor.constraint(equalTo: bottomAnchor), ]) self.view = view + + updateForcesEarlySwiftUIRendering() } override public func preferredLayoutAttributesFitting( @@ -76,4 +86,12 @@ public final class CollectionViewReusableView: UICollectionReusableView { /// post-update data. var itemPath: SupplementaryItemPath? + // MARK: Private + + private func updateForcesEarlySwiftUIRendering() { + if let configurableView = view as? SwiftUIRenderingConfigurable { + configurableView.forcesEarlySwiftUIRendering = forcesEarlySwiftUIRendering + } + } + } diff --git a/Sources/EpoxyCore/SwiftUI/EpoxySwiftUIHostingView.swift b/Sources/EpoxyCore/SwiftUI/EpoxySwiftUIHostingView.swift index 0eab49e0..e93ca373 100644 --- a/Sources/EpoxyCore/SwiftUI/EpoxySwiftUIHostingView.swift +++ b/Sources/EpoxyCore/SwiftUI/EpoxySwiftUIHostingView.swift @@ -57,7 +57,7 @@ extension CallbackContextEpoxyModeled /// the API is private and 3) the `_UIHostingView` doesn't not accept setting a new `View` instance. /// /// - SeeAlso: `EpoxySwiftUIHostingController` -public final class EpoxySwiftUIHostingView: UIView, EpoxyableView { +public final class EpoxySwiftUIHostingView: UIView, EpoxyableView, SwiftUIRenderingConfigurable { // MARK: Lifecycle @@ -130,6 +130,9 @@ public final class EpoxySwiftUIHostingView: UIView, EpoxyableVie } } + /// See `CollectionViewConfiguration.forcesEarlySwiftUIRendering` for an explanation of this behavior. + public var forcesEarlySwiftUIRendering = true + public override func didMoveToWindow() { super.didMoveToWindow() @@ -183,12 +186,18 @@ public final class EpoxySwiftUIHostingView: UIView, EpoxyableVie // The view controller must be added to the view controller hierarchy to measure its content. addViewControllerIfNeededAndReady() - // As of iOS 15.2, `UIHostingController` now renders updated content asynchronously, and as such - // this view will get sized incorrectly with the previous content when reused unless we invoke - // this semi-private API. We couldn't find any other method to get the view to resize - // synchronously after updating `rootView`, but hopefully this will become a public API soon so - // we can remove this call. - viewController._render(seconds: 0) + if forcesEarlySwiftUIRendering { + // As of iOS 15.2, `UIHostingController` now renders updated content asynchronously, and as such + // this view will get sized incorrectly with the previous content when reused unless we invoke + // this semi-private API. We couldn't find any other method to get the view to resize + // synchronously after updating `rootView`, but hopefully this will become a public API soon so + // we can remove this call. + viewController._render(seconds: 0) + } else { + // We need to layout the view to ensure it gets resized properly when cells are re-used + viewController.view.setNeedsLayout() + viewController.view.layoutIfNeeded() + } // This is required to ensure that views with new content are properly resized. viewController.view.invalidateIntrinsicContentSize() @@ -343,10 +352,6 @@ public final class EpoxySwiftUIHostingView: UIView, EpoxyableVie addSubview(viewController.view) - // Get the view controller's view to be sized correctly so that we don't have to wait for - // autolayout to perform a pass to do so. - viewController.view.frame = bounds - viewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ viewController.view.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -432,3 +437,10 @@ struct EpoxyHostingWrapper: View { } #endif + +// MARK: - SwiftUIRenderingConfigurable + +public protocol SwiftUIRenderingConfigurable: AnyObject { + /// See `CollectionViewConfiguration.forcesEarlySwiftUIRendering` for an explanation of this behavior. + var forcesEarlySwiftUIRendering: Bool { get set } +}