Skip to content

Commit

Permalink
[SwiftUI] Adds forcesEarlySwiftUIRendering flag to fix SwiftUI Cell S…
Browse files Browse the repository at this point in the history
…izing (#153)

* [SwiftUI] Fix reused UIHostingController sizing

* Added to collection view configuration

---------

Co-authored-by: michael gofron <[email protected]>
  • Loading branch information
brynbodayle and michael gofron authored Sep 22, 2023
1 parent 9d17ae9 commit bb59fcc
Show file tree
Hide file tree
Showing 6 changed files with 72 additions and 14 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ open class CollectionView: UICollectionView {
}

cell.itemPath = itemPath
cell.forcesEarlySwiftUIRendering = configuration.forcesEarlySwiftUIRendering

let metadata = ItemCellMetadata(
traitCollection: traitCollection,
Expand All @@ -469,6 +470,7 @@ open class CollectionView: UICollectionView {
animated: Bool)
{
supplementaryView.itemPath = itemPath
supplementaryView.forcesEarlySwiftUIRendering = configuration.forcesEarlySwiftUIRendering
model.configure(
reusableView: supplementaryView,
traitCollection: traitCollection,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -152,6 +161,12 @@ public final class CollectionViewCell: UICollectionViewCell, ItemCellView {
}
}

private func updateForcesEarlySwiftUIRendering() {
if let configurableView = view as? SwiftUIRenderingConfigurable {
configurableView.forcesEarlySwiftUIRendering = forcesEarlySwiftUIRendering
}
}

}

// MARK: EphemeralCachedStateView
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 {
Expand All @@ -39,6 +47,8 @@ public final class CollectionViewReusableView: UICollectionReusableView {
view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
self.view = view

updateForcesEarlySwiftUIRendering()
}

override public func preferredLayoutAttributesFitting(
Expand Down Expand Up @@ -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
}
}

}
34 changes: 23 additions & 11 deletions Sources/EpoxyCore/SwiftUI/EpoxySwiftUIHostingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootView: View>: UIView, EpoxyableView {
public final class EpoxySwiftUIHostingView<RootView: View>: UIView, EpoxyableView, SwiftUIRenderingConfigurable {

// MARK: Lifecycle

Expand Down Expand Up @@ -130,6 +130,9 @@ public final class EpoxySwiftUIHostingView<RootView: View>: UIView, EpoxyableVie
}
}

/// See `CollectionViewConfiguration.forcesEarlySwiftUIRendering` for an explanation of this behavior.
public var forcesEarlySwiftUIRendering = true

public override func didMoveToWindow() {
super.didMoveToWindow()

Expand Down Expand Up @@ -183,12 +186,18 @@ public final class EpoxySwiftUIHostingView<RootView: View>: 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()
Expand Down Expand Up @@ -343,10 +352,6 @@ public final class EpoxySwiftUIHostingView<RootView: View>: 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),
Expand Down Expand Up @@ -432,3 +437,10 @@ struct EpoxyHostingWrapper<Content: View>: View {
}

#endif

// MARK: - SwiftUIRenderingConfigurable

public protocol SwiftUIRenderingConfigurable: AnyObject {
/// See `CollectionViewConfiguration.forcesEarlySwiftUIRendering` for an explanation of this behavior.
var forcesEarlySwiftUIRendering: Bool { get set }
}

0 comments on commit bb59fcc

Please sign in to comment.