diff --git a/.swiftformat b/.swiftformat index 9abea57e..a79ec7dd 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,5 +1,5 @@ # Don't format ---exclude .build +--exclude .build,UI/UIx/SwiftUI/Epoxy # Options diff --git a/UI/UIx/SwiftUI/Epoxy/CollectionViewScrollToItemHelper.swift b/UI/UIx/SwiftUI/Epoxy/CollectionViewScrollToItemHelper.swift index bbd4ce11..a8874d84 100644 --- a/UI/UIx/SwiftUI/Epoxy/CollectionViewScrollToItemHelper.swift +++ b/UI/UIx/SwiftUI/Epoxy/CollectionViewScrollToItemHelper.swift @@ -1,10 +1,7 @@ -// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112 - // Created by Bryan Keller on 10/20/20. // Copyright © 2020 Airbnb Inc. All rights reserved. import UIKit -import VLogging // MARK: - CollectionViewScrollToItemHelper @@ -17,455 +14,443 @@ import VLogging /// The fix for the non-animated case involves repeatedly calling the UIKit `scrollToItem` /// implementation until we land at a stable content offset. final class CollectionViewScrollToItemHelper { - // MARK: Lifecycle - /// The collection view instance is weakly-held. - init(collectionView: UICollectionView) { - self.collectionView = collectionView - } + // MARK: Lifecycle - // MARK: Internal - - func accuratelyScrollToItem( - at indexPath: IndexPath, - position: UICollectionView.ScrollPosition, - animated: Bool - ) { - if animated { - accurateScrollToItemWithAnimation(itemIndexPath: indexPath, position: position) - } else { - accurateScrollToItemWithoutAnimation(itemIndexPath: indexPath, position: position) - } - } + /// The collection view instance is weakly-held. + init(collectionView: UICollectionView) { + self.collectionView = collectionView + } - /// Cancels an in-flight animated scroll-to-item, if there is one. - /// - /// Call this function if your collection view is about to deallocate. For example, you can call - /// this from `viewWillDisappear` in a view controller, or `didMoveToWindow` when `window == nil` - /// in a view. You can also call this when a user interacts with the collection view so that - /// control is returned to the user. - func cancelAnimatedScrollToItem() { - scrollToItemContext = nil - } + // MARK: Internal - // MARK: Private + func accuratelyScrollToItem( + at indexPath: IndexPath, + position: UICollectionView.ScrollPosition, + animated: Bool) + { + if animated { + accurateScrollToItemWithAnimation(itemIndexPath: indexPath, position: position) + } else { + accurateScrollToItemWithoutAnimation(itemIndexPath: indexPath, position: position) + } + } + + /// Cancels an in-flight animated scroll-to-item, if there is one. + /// + /// Call this function if your collection view is about to deallocate. For example, you can call + /// this from `viewWillDisappear` in a view controller, or `didMoveToWindow` when `window == nil` + /// in a view. You can also call this when a user interacts with the collection view so that + /// control is returned to the user. + func cancelAnimatedScrollToItem() { + scrollToItemContext = nil + } + + // MARK: Private + + private weak var collectionView: UICollectionView? + private weak var scrollToItemDisplayLink: CADisplayLink? + + private var scrollToItemContext: ScrollToItemContext? { + willSet { + scrollToItemDisplayLink?.invalidate() + } + } + + private func accurateScrollToItemWithoutAnimation( + itemIndexPath: IndexPath, + position: UICollectionView.ScrollPosition) + { + guard let collectionView = collectionView else { return } + + // Programmatically scrolling to an item, even without an animation, when using self-sizing + // cells usually results in slightly incorrect scroll offsets. By invoking `scrollToItem` + // multiple times in a row, we can force the collection view to eventually end up in the right + // spot. + // + // This usually only takes 3 iterations: 1 to get to an estimated offset, 1 to get to the + // final offset, and 1 to verify that we're at the final offset. If it takes more than 5 + // attempts, we'll stop trying since we're blocking the main thread during these attempts. + var previousContentOffset = CGPoint( + x: CGFloat.greatestFiniteMagnitude, + y: CGFloat.greatestFiniteMagnitude) + var numberOfAttempts = 1 + while + + abs(collectionView.contentOffset.x - previousContentOffset.x) >= 1 || + abs(collectionView.contentOffset.y - previousContentOffset.y) >= 1, + numberOfAttempts <= 5 + { + if numberOfAttempts > 1 { + collectionView.setNeedsLayout() + collectionView.layoutIfNeeded() + } - private weak var collectionView: UICollectionView? - private weak var scrollToItemDisplayLink: CADisplayLink? + previousContentOffset = collectionView.contentOffset + collectionView.scrollToItem(at: itemIndexPath, at: position, animated: false) - private var scrollToItemContext: ScrollToItemContext? { - willSet { - scrollToItemDisplayLink?.invalidate() - } + numberOfAttempts += 1 } - private func accurateScrollToItemWithoutAnimation( - itemIndexPath: IndexPath, - position: UICollectionView.ScrollPosition - ) { - guard let collectionView else { return } - - // Programmatically scrolling to an item, even without an animation, when using self-sizing - // cells usually results in slightly incorrect scroll offsets. By invoking `scrollToItem` - // multiple times in a row, we can force the collection view to eventually end up in the right - // spot. - // - // This usually only takes 3 iterations: 1 to get to an estimated offset, 1 to get to the - // final offset, and 1 to verify that we're at the final offset. If it takes more than 5 - // attempts, we'll stop trying since we're blocking the main thread during these attempts. - var previousContentOffset = CGPoint( - x: CGFloat.greatestFiniteMagnitude, - y: CGFloat.greatestFiniteMagnitude - ) - var numberOfAttempts = 1 - while - - abs(collectionView.contentOffset.x - previousContentOffset.x) >= 1 || - abs(collectionView.contentOffset.y - previousContentOffset.y) >= 1, - numberOfAttempts <= 5 - { - if numberOfAttempts > 1 { - collectionView.setNeedsLayout() - collectionView.layoutIfNeeded() - } - - previousContentOffset = collectionView.contentOffset - collectionView.scrollToItem(at: itemIndexPath, at: position, animated: false) - - numberOfAttempts += 1 - } - - if numberOfAttempts > 5 { - logger.warning( - "Gave up scrolling to an item without an animation because it took more than 5 attempts.") - } + if numberOfAttempts > 5 { + EpoxyLogger.shared.warn( + "Gave up scrolling to an item without an animation because it took more than 5 attempts.") } - - private func accurateScrollToItemWithAnimation( - itemIndexPath: IndexPath, - position: UICollectionView.ScrollPosition - ) { - guard let collectionView else { return } - - let scrollPosition: UICollectionView.ScrollPosition - if position == [] { - guard - let closestScrollPosition = closestRestingScrollPosition( - forTargetItemIndexPath: itemIndexPath, - collectionView: collectionView - ) - else { - // If we can't find a closest-scroll-position, it's because the item is already fully - // visible. In this situation, we can return early / do nothing. - return - } - scrollPosition = closestScrollPosition - } else { - scrollPosition = position - } - - scrollToItemContext = ScrollToItemContext( - targetIndexPath: itemIndexPath, - targetScrollPosition: scrollPosition, - animationStartTime: CACurrentMediaTime() - ) - - startScrollingTowardTargetItem() + } + + private func accurateScrollToItemWithAnimation( + itemIndexPath: IndexPath, + position: UICollectionView.ScrollPosition) + { + guard let collectionView = collectionView else { return } + + let scrollPosition: UICollectionView.ScrollPosition + if position == [] { + guard + let closestScrollPosition = closestRestingScrollPosition( + forTargetItemIndexPath: itemIndexPath, + collectionView: collectionView) + else { + // If we can't find a closest-scroll-position, it's because the item is already fully + // visible. In this situation, we can return early / do nothing. + return + } + scrollPosition = closestScrollPosition + } else { + scrollPosition = position } - private func startScrollingTowardTargetItem() { - let scrollToItemDisplayLink = CADisplayLink( - target: self, - selector: #selector(scrollToItemDisplayLinkFired) - ) - if #available(iOS 15.1, *) { - #if swift(>=5.5) // Proxy check for being built with the iOS 14 & below SDK, running on iOS 15. - scrollToItemDisplayLink.preferredFrameRateRange = CAFrameRateRange( - minimum: 80, - maximum: 120, - preferred: 120 - ) - #endif - } - scrollToItemDisplayLink.add(to: .main, forMode: .common) - self.scrollToItemDisplayLink = scrollToItemDisplayLink + scrollToItemContext = ScrollToItemContext( + targetIndexPath: itemIndexPath, + targetScrollPosition: scrollPosition, + animationStartTime: CACurrentMediaTime()) + + startScrollingTowardTargetItem() + } + + private func startScrollingTowardTargetItem() { + let scrollToItemDisplayLink = CADisplayLink( + target: self, + selector: #selector(scrollToItemDisplayLinkFired)) + if #available(iOS 15.1, *) { + #if swift(>=5.5) // Proxy check for being built with the iOS 14 & below SDK, running on iOS 15. + scrollToItemDisplayLink.preferredFrameRateRange = CAFrameRateRange( + minimum: 80, + maximum: 120, + preferred: 120) + #endif } - - /// Removes our scroll-to-item context and finalizes our custom scroll-to-item by invoking the - /// original function. This guarantees that our last frame of animation ends us in the correct - /// position. - private func finalizeScrollingTowardItem( - for scrollToItemContext: ScrollToItemContext, - animated: Bool - ) { - self.scrollToItemContext = nil - - guard let collectionView else { return } - - // Calling `scrollToItem(…)` with in invalid index path raises an exception: - // > NSInternalInconsistencyException: Attempted to scroll the collection view to an out-of- - // > bounds item - // We must guard against this to check to ensure that this never happens, as we call this method - // repeatedly and the items/section may change out from under us. - if - case let indexPath = scrollToItemContext.targetIndexPath, - indexPath.section < collectionView.numberOfSections, - indexPath.item < collectionView.numberOfItems(inSection: indexPath.section) - { - collectionView.scrollToItem( - at: indexPath, - at: scrollToItemContext.targetScrollPosition, - animated: animated - ) - } - - if !animated { - collectionView.delegate?.scrollViewDidEndScrollingAnimation?(collectionView) - } + scrollToItemDisplayLink.add(to: .main, forMode: .common) + self.scrollToItemDisplayLink = scrollToItemDisplayLink + } + + /// Removes our scroll-to-item context and finalizes our custom scroll-to-item by invoking the + /// original function. This guarantees that our last frame of animation ends us in the correct + /// position. + private func finalizeScrollingTowardItem( + for scrollToItemContext: ScrollToItemContext, + animated: Bool) + { + self.scrollToItemContext = nil + + guard let collectionView = collectionView else { return } + + // Calling `scrollToItem(…)` with in invalid index path raises an exception: + // > NSInternalInconsistencyException: Attempted to scroll the collection view to an out-of- + // > bounds item + // We must guard against this to check to ensure that this never happens, as we call this method + // repeatedly and the items/section may change out from under us. + if + case let indexPath = scrollToItemContext.targetIndexPath, + indexPath.section < collectionView.numberOfSections, + indexPath.item < collectionView.numberOfItems(inSection: indexPath.section) + { + collectionView.scrollToItem( + at: indexPath, + at: scrollToItemContext.targetScrollPosition, + animated: animated) } - @objc - private func scrollToItemDisplayLinkFired() { - guard let collectionView else { return } - guard let scrollToItemContext else { - assertionFailure( - """ - Expected `scrollToItemContext` to be non-nil when programmatically scrolling toward an \ - item. - """) - return - } - - // Don't start programmatically scrolling until we have a greater-than`.zero` `bounds.size`. - // This might happen if `scrollToItem` is called before the collection view has been laid out. - guard collectionView.bounds.width > 0, collectionView.bounds.height > 0 else { return } - - // Figure out which axis to use for scrolling. - guard let scrollAxis = scrollAxis(for: collectionView) else { - // If we can't determine a scroll axis, it's either due to the collection view being too small - // to be scrollable along either axis, or the collection view being scrollable along both - // axes. In either scenario, we can just fall back to the default scroll-to-item behavior. - finalizeScrollingTowardItem(for: scrollToItemContext, animated: true) - return - } - - let maximumPerAnimationTickOffset = maximumPerAnimationTickOffset( - for: scrollAxis, - collectionView: collectionView - ) - - // After 3 seconds, the scrolling reaches is maximum speed. - let secondsSinceAnimationStart = CACurrentMediaTime() - scrollToItemContext.animationStartTime - let offset = maximumPerAnimationTickOffset * CGFloat(min(secondsSinceAnimationStart / 3, 1)) - - // Apply this scroll animation "tick's" offset adjustment. This is what actually causes the - // scroll position to change, giving the illusion of smooth scrolling as this happens 60+ times - // per second. - let positionBeforeLayout = positionRelativeToVisibleBounds( - forTargetItemIndexPath: scrollToItemContext.targetIndexPath, - collectionView: collectionView - ) - - switch positionBeforeLayout { - case .before: - collectionView.contentOffset[scrollAxis] -= offset - - case .after: - collectionView.contentOffset[scrollAxis] += offset - - // If the target item is partially or fully visible, then we don't need to apply a full `offset` - // adjustment of the content offset. Instead, we do some special logic to look at how close we - // currently are to the target origin, then change our content offset based on how far away we - // are from that target. - case .partiallyOrFullyVisible(let frame): - let targetContentOffset = targetContentOffsetForVisibleItem( - withFrame: frame, - inBounds: collectionView.bounds, - contentSize: collectionView.contentSize, - adjustedContentInset: collectionView.adjustedContentInset, - targetScrollPosition: scrollToItemContext.targetScrollPosition, - scrollAxis: scrollAxis - ) - - let targetOffset = targetContentOffset[scrollAxis] - let currentOffset = collectionView.contentOffset[scrollAxis] - let distanceToTargetOffset = targetOffset - currentOffset - - switch distanceToTargetOffset { - case ...(-1): - collectionView.contentOffset[scrollAxis] += max(-offset, distanceToTargetOffset) - case 1...: - collectionView.contentOffset[scrollAxis] += min(offset, distanceToTargetOffset) - default: - finalizeScrollingTowardItem(for: scrollToItemContext, animated: false) - } - - case .none: - break - } - - collectionView.setNeedsLayout() - collectionView.layoutIfNeeded() + if !animated { + collectionView.delegate?.scrollViewDidEndScrollingAnimation?(collectionView) + } + } + + @objc + private func scrollToItemDisplayLinkFired() { + guard let collectionView = collectionView else { return } + guard let scrollToItemContext = scrollToItemContext else { + EpoxyLogger.shared.assertionFailure( + """ + Expected `scrollToItemContext` to be non-nil when programmatically scrolling toward an \ + item. + """) + return } - private func scrollAxis(for collectionView: UICollectionView) -> ScrollAxis? { - let availableWidth = collectionView.bounds.width - - collectionView.adjustedContentInset.left - - collectionView.adjustedContentInset.right - let availableHeight = collectionView.bounds.height - - collectionView.adjustedContentInset.top - - collectionView.adjustedContentInset.bottom - let scrollsHorizontally = collectionView.contentSize.width > availableWidth - let scrollsVertically = collectionView.contentSize.height > availableHeight - - switch (scrollsHorizontally: scrollsHorizontally, scrollsVertically: scrollsVertically) { - case (scrollsHorizontally: false, scrollsVertically: true): - return .vertical - - case (scrollsHorizontally: true, scrollsVertically: false): - return .horizontal - - case (scrollsHorizontally: true, scrollsVertically: true), - (scrollsHorizontally: false, scrollsVertically: false): - return nil - } + // Don't start programmatically scrolling until we have a greater-than`.zero` `bounds.size`. + // This might happen if `scrollToItem` is called before the collection view has been laid out. + guard collectionView.bounds.width > 0, collectionView.bounds.height > 0 else { return } + + // Figure out which axis to use for scrolling. + guard let scrollAxis = scrollAxis(for: collectionView) else { + // If we can't determine a scroll axis, it's either due to the collection view being too small + // to be scrollable along either axis, or the collection view being scrollable along both + // axes. In either scenario, we can just fall back to the default scroll-to-item behavior. + finalizeScrollingTowardItem(for: scrollToItemContext, animated: true) + return } - private func maximumPerAnimationTickOffset( - for scrollAxis: ScrollAxis, - collectionView: UICollectionView - ) - -> CGFloat - { - let offset: CGFloat - switch scrollAxis { - case .vertical: offset = collectionView.bounds.height - case .horizontal: offset = collectionView.bounds.width - } + let maximumPerAnimationTickOffset = maximumPerAnimationTickOffset( + for: scrollAxis, + collectionView: collectionView) + + // After 3 seconds, the scrolling reaches is maximum speed. + let secondsSinceAnimationStart = CACurrentMediaTime() - scrollToItemContext.animationStartTime + let offset = maximumPerAnimationTickOffset * CGFloat(min(secondsSinceAnimationStart / 3, 1)) + + // Apply this scroll animation "tick's" offset adjustment. This is what actually causes the + // scroll position to change, giving the illusion of smooth scrolling as this happens 60+ times + // per second. + let positionBeforeLayout = positionRelativeToVisibleBounds( + forTargetItemIndexPath: scrollToItemContext.targetIndexPath, + collectionView: collectionView) + + switch positionBeforeLayout { + case .before: + collectionView.contentOffset[scrollAxis] -= offset + + case .after: + collectionView.contentOffset[scrollAxis] += offset + + // If the target item is partially or fully visible, then we don't need to apply a full `offset` + // adjustment of the content offset. Instead, we do some special logic to look at how close we + // currently are to the target origin, then change our content offset based on how far away we + // are from that target. + case .partiallyOrFullyVisible(let frame): + let targetContentOffset = targetContentOffsetForVisibleItem( + withFrame: frame, + inBounds: collectionView.bounds, + contentSize: collectionView.contentSize, + adjustedContentInset: collectionView.adjustedContentInset, + targetScrollPosition: scrollToItemContext.targetScrollPosition, + scrollAxis: scrollAxis) + + let targetOffset = targetContentOffset[scrollAxis] + let currentOffset = collectionView.contentOffset[scrollAxis] + let distanceToTargetOffset = targetOffset - currentOffset + + switch distanceToTargetOffset { + case ...(-1): + collectionView.contentOffset[scrollAxis] += max(-offset, distanceToTargetOffset) + case 1...: + collectionView.contentOffset[scrollAxis] += min(offset, distanceToTargetOffset) + default: + finalizeScrollingTowardItem(for: scrollToItemContext, animated: false) + } + + case .none: + break + } - return offset * 1.5 + collectionView.setNeedsLayout() + collectionView.layoutIfNeeded() + } + + private func scrollAxis(for collectionView: UICollectionView) -> ScrollAxis? { + let availableWidth = collectionView.bounds.width - + collectionView.adjustedContentInset.left - + collectionView.adjustedContentInset.right + let availableHeight = collectionView.bounds.height - + collectionView.adjustedContentInset.top - + collectionView.adjustedContentInset.bottom + let scrollsHorizontally = collectionView.contentSize.width > availableWidth + let scrollsVertically = collectionView.contentSize.height > availableHeight + + switch (scrollsHorizontally: scrollsHorizontally, scrollsVertically: scrollsVertically) { + case (scrollsHorizontally: false, scrollsVertically: true): + return .vertical + + case (scrollsHorizontally: true, scrollsVertically: false): + return .horizontal + + case (scrollsHorizontally: true, scrollsVertically: true), + (scrollsHorizontally: false, scrollsVertically: false): + return nil + } + } + + private func maximumPerAnimationTickOffset( + for scrollAxis: ScrollAxis, + collectionView: UICollectionView) + -> CGFloat + { + let offset: CGFloat + switch scrollAxis { + case .vertical: offset = collectionView.bounds.height + case .horizontal: offset = collectionView.bounds.width } - /// Returns the position (before, after, visible) of an item relative to the current viewport. - /// Note that the position (before, after, visible) is agnostic of scroll axis. - private func positionRelativeToVisibleBounds( - forTargetItemIndexPath targetIndexPath: IndexPath, - collectionView: UICollectionView - ) - -> PositionRelativeToVisibleBounds? + return offset * 1.5 + } + + /// Returns the position (before, after, visible) of an item relative to the current viewport. + /// Note that the position (before, after, visible) is agnostic of scroll axis. + private func positionRelativeToVisibleBounds( + forTargetItemIndexPath targetIndexPath: IndexPath, + collectionView: UICollectionView) + -> PositionRelativeToVisibleBounds? + { + let indexPathsForVisibleItems = collectionView.indexPathsForVisibleItems.sorted() + + if let targetItemFrame = collectionView.layoutAttributesForItem(at: targetIndexPath)?.frame { + return .partiallyOrFullyVisible(frame: targetItemFrame) + } else if + let firstVisibleIndexPath = indexPathsForVisibleItems.first, + targetIndexPath < firstVisibleIndexPath + { + return .before + } else if + let lastVisibleIndexPath = indexPathsForVisibleItems.last, + targetIndexPath > lastVisibleIndexPath { - let indexPathsForVisibleItems = collectionView.indexPathsForVisibleItems.sorted() - - if let targetItemFrame = collectionView.layoutAttributesForItem(at: targetIndexPath)?.frame { - return .partiallyOrFullyVisible(frame: targetItemFrame) - } else if - let firstVisibleIndexPath = indexPathsForVisibleItems.first, - targetIndexPath < firstVisibleIndexPath - { - return .before - } else if - let lastVisibleIndexPath = indexPathsForVisibleItems.last, - targetIndexPath > lastVisibleIndexPath - { - return .after - } else { - assertionFailure( - "Could not find a position relative to the visible bounds for item at \(targetIndexPath)") - return nil - } + return .after + } else { + EpoxyLogger.shared.assertionFailure( + "Could not find a position relative to the visible bounds for item at \(targetIndexPath)") + return nil + } + } + + /// If a scroll position is not specified, this function is called to find the closest scroll + /// position to make the item as visible as possible. If the item is already completely visible, + /// this function returns `nil`. + private func closestRestingScrollPosition( + forTargetItemIndexPath targetIndexPath: IndexPath, + collectionView: UICollectionView) + -> UICollectionView.ScrollPosition? + { + guard let scrollAxis = scrollAxis(for: collectionView) else { + return nil } - /// If a scroll position is not specified, this function is called to find the closest scroll - /// position to make the item as visible as possible. If the item is already completely visible, - /// this function returns `nil`. - private func closestRestingScrollPosition( - forTargetItemIndexPath targetIndexPath: IndexPath, - collectionView: UICollectionView - ) - -> UICollectionView.ScrollPosition? - { - guard let scrollAxis = scrollAxis(for: collectionView) else { - return nil - } - - let positionRelativeToVisibleBounds = positionRelativeToVisibleBounds( - forTargetItemIndexPath: targetIndexPath, - collectionView: collectionView - ) - - let insetBounds = collectionView.bounds.inset(by: collectionView.adjustedContentInset) - - switch (scrollAxis, positionRelativeToVisibleBounds) { - case (.vertical, .before): - return .top - case (.vertical, .after): - return .bottom - case (.vertical, .partiallyOrFullyVisible(let itemFrame)): - guard !insetBounds.contains(itemFrame) else { return nil } - return itemFrame.midY < insetBounds.midY ? .top : .bottom - case (.horizontal, .before): - return .left - case (.horizontal, .after): - return .right - case (.horizontal, .partiallyOrFullyVisible(let itemFrame)): - guard !insetBounds.contains(itemFrame) else { return nil } - return itemFrame.midX < insetBounds.midX ? .left : .right - default: - assertionFailure("Unsupported scroll position.") - return nil - } + let positionRelativeToVisibleBounds = positionRelativeToVisibleBounds( + forTargetItemIndexPath: targetIndexPath, + collectionView: collectionView) + + let insetBounds = collectionView.bounds.inset(by: collectionView.adjustedContentInset) + + switch (scrollAxis, positionRelativeToVisibleBounds) { + case (.vertical, .before): + return .top + case (.vertical, .after): + return .bottom + case (.vertical, .partiallyOrFullyVisible(let itemFrame)): + guard !insetBounds.contains(itemFrame) else { return nil } + return itemFrame.midY < insetBounds.midY ? .top : .bottom + case (.horizontal, .before): + return .left + case (.horizontal, .after): + return .right + case (.horizontal, .partiallyOrFullyVisible(let itemFrame)): + guard !insetBounds.contains(itemFrame) else { return nil } + return itemFrame.midX < insetBounds.midX ? .left : .right + default: + EpoxyLogger.shared.assertionFailure("Unsupported scroll position.") + return nil + } + } + + /// Returns the correct content offset for a scroll-to-item action for the current viewport. + /// + /// This will be used to determine how much farther we need to programmatically scroll on each + /// animation tick. + private func targetContentOffsetForVisibleItem( + withFrame itemFrame: CGRect, + inBounds bounds: CGRect, + contentSize: CGSize, + adjustedContentInset: UIEdgeInsets, + targetScrollPosition: UICollectionView.ScrollPosition, + scrollAxis: ScrollAxis) + -> CGPoint + { + let itemPosition, itemSize, viewportSize, minContentOffset, maxContentOffset: CGFloat + let visibleBounds = bounds.inset(by: adjustedContentInset) + switch scrollAxis { + case .vertical: + itemPosition = itemFrame.minY + itemSize = itemFrame.height + viewportSize = visibleBounds.height + minContentOffset = -adjustedContentInset.top + maxContentOffset = -adjustedContentInset.top + contentSize.height - visibleBounds.height + case .horizontal: + itemPosition = itemFrame.minX + itemSize = itemFrame.width + viewportSize = visibleBounds.width + minContentOffset = -adjustedContentInset.left + maxContentOffset = -adjustedContentInset.left + contentSize.width - visibleBounds.width } - /// Returns the correct content offset for a scroll-to-item action for the current viewport. - /// - /// This will be used to determine how much farther we need to programmatically scroll on each - /// animation tick. - private func targetContentOffsetForVisibleItem( - withFrame itemFrame: CGRect, - inBounds bounds: CGRect, - contentSize: CGSize, - adjustedContentInset: UIEdgeInsets, - targetScrollPosition: UICollectionView.ScrollPosition, - scrollAxis: ScrollAxis - ) - -> CGPoint - { - let itemPosition, itemSize, viewportSize, minContentOffset, maxContentOffset: CGFloat - let visibleBounds = bounds.inset(by: adjustedContentInset) - switch scrollAxis { - case .vertical: - itemPosition = itemFrame.minY - itemSize = itemFrame.height - viewportSize = visibleBounds.height - minContentOffset = -adjustedContentInset.top - maxContentOffset = -adjustedContentInset.top + contentSize.height - visibleBounds.height - case .horizontal: - itemPosition = itemFrame.minX - itemSize = itemFrame.width - viewportSize = visibleBounds.width - minContentOffset = -adjustedContentInset.left - maxContentOffset = -adjustedContentInset.left + contentSize.width - visibleBounds.width - } - - let newOffset: CGFloat - switch targetScrollPosition { - case .top, .left: - newOffset = itemPosition + minContentOffset - case .bottom, .right: - newOffset = itemPosition + itemSize - viewportSize + minContentOffset - case .centeredVertically, .centeredHorizontally: - newOffset = itemPosition + (itemSize / 2) - (viewportSize / 2) + minContentOffset - default: - assertionFailure("Unsupported scroll position.") - return itemFrame.origin - } - - let clampedOffset = min(max(newOffset, minContentOffset), maxContentOffset) - - var targetOffset = itemFrame.origin - targetOffset[scrollAxis] = clampedOffset - return targetOffset + let newOffset: CGFloat + switch targetScrollPosition { + case .top, .left: + newOffset = itemPosition + minContentOffset + case .bottom, .right: + newOffset = itemPosition + itemSize - viewportSize + minContentOffset + case .centeredVertically, .centeredHorizontally: + newOffset = itemPosition + (itemSize / 2) - (viewportSize / 2) + minContentOffset + default: + EpoxyLogger.shared.assertionFailure("Unsupported scroll position.") + return itemFrame.origin } + + let clampedOffset = min(max(newOffset, minContentOffset), maxContentOffset) + + var targetOffset = itemFrame.origin + targetOffset[scrollAxis] = clampedOffset + return targetOffset + } + } // MARK: - ScrollToItemContext private struct ScrollToItemContext { - let targetIndexPath: IndexPath - let targetScrollPosition: UICollectionView.ScrollPosition - let animationStartTime: CFTimeInterval + let targetIndexPath: IndexPath + let targetScrollPosition: UICollectionView.ScrollPosition + let animationStartTime: CFTimeInterval } // MARK: - ScrollAxis private enum ScrollAxis { - case vertical - case horizontal + case vertical + case horizontal } // MARK: - PositionRelativeToVisibleBounds private enum PositionRelativeToVisibleBounds { - case before - case after - case partiallyOrFullyVisible(frame: CGRect) + case before + case after + case partiallyOrFullyVisible(frame: CGRect) } // MARK: - CGPoint extension CGPoint { - fileprivate subscript(axis: ScrollAxis) -> CGFloat { - get { - switch axis { - case .vertical: return y - case .horizontal: return x - } - } - set { - switch axis { - case .vertical: y = newValue - case .horizontal: x = newValue - } - } + fileprivate subscript(axis: ScrollAxis) -> CGFloat { + get { + switch axis { + case .vertical: return y + case .horizontal: return x + } + } + set { + switch axis { + case .vertical: y = newValue + case .horizontal: x = newValue + } } + } } diff --git a/UI/UIx/SwiftUI/Epoxy/DataIDProviding.swift b/UI/UIx/SwiftUI/Epoxy/DataIDProviding.swift index 22a96b7d..4025e350 100644 --- a/UI/UIx/SwiftUI/Epoxy/DataIDProviding.swift +++ b/UI/UIx/SwiftUI/Epoxy/DataIDProviding.swift @@ -1,17 +1,57 @@ -// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112 - // Created by eric_horacek on 12/1/20. // Copyright © 2020 Airbnb Inc. All rights reserved. +// MARK: - DataIDProviding + +/// The capability of providing a stable data identifier with an erased type. +/// +/// While it has similar semantics, this type cannot inherit from `Identifiable` as this would give +/// it an associated type, which would cause the `keyPath` used in its `EpoxyModelProperty` to not +/// be stable across types if written as `\Self.dataID` since the `KeyPath` `Root` would be +/// different for each type. +/// +/// - SeeAlso: `Identifiable`. +public protocol DataIDProviding { + /// A stable identifier that uniquely identifies this instance, with its typed erased. + /// + /// Defaults to `DefaultDataID.noneProvided` if no data ID is provided. + var dataID: AnyHashable { get } +} + +// MARK: - EpoxyModeled + +extension EpoxyModeled where Self: DataIDProviding { + + // MARK: Public + + /// A stable identifier that uniquely identifies this model, with its typed erased. + public var dataID: AnyHashable { + get { self[dataIDProperty] } + set { self[dataIDProperty] = newValue } + } + + /// Returns a copy of this model with the ID replaced with the provided ID. + public func dataID(_ value: AnyHashable) -> Self { + copy(updating: dataIDProperty, to: value) + } + + // MARK: Private + + private var dataIDProperty: EpoxyModelProperty { + EpoxyModelProperty( + keyPath: \DataIDProviding.dataID, + defaultValue: DefaultDataID.noneProvided, + updateStrategy: .replace) + } +} + // MARK: - DefaultDataID /// The default data ID when none is provided. public enum DefaultDataID: Hashable, CustomDebugStringConvertible { - case noneProvided - - // MARK: Public + case noneProvided - public var debugDescription: String { - "DefaultDataID.noneProvided" - } + public var debugDescription: String { + "DefaultDataID.noneProvided" + } } diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxyIntrinsicContentSizeInvalidator.swift b/UI/UIx/SwiftUI/Epoxy/EpoxyIntrinsicContentSizeInvalidator.swift index 95197bd9..05e7f0f6 100644 --- a/UI/UIx/SwiftUI/Epoxy/EpoxyIntrinsicContentSizeInvalidator.swift +++ b/UI/UIx/SwiftUI/Epoxy/EpoxyIntrinsicContentSizeInvalidator.swift @@ -1,5 +1,3 @@ -// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112 - // Created by matthew_cheok on 11/19/21. // Copyright © 2021 Airbnb Inc. All rights reserved. @@ -21,29 +19,25 @@ import SwiftUI /// } /// ``` public struct EpoxyIntrinsicContentSizeInvalidator { - // MARK: Public - - public func callAsFunction() { - invalidate() - } - - // MARK: Internal + let invalidate: () -> Void - let invalidate: () -> Void + public func callAsFunction() { + invalidate() + } } // MARK: - EnvironmentValues extension EnvironmentValues { - /// A means of invalidating the intrinsic content size of the parent `EpoxySwiftUIHostingView`. - public var epoxyIntrinsicContentSizeInvalidator: EpoxyIntrinsicContentSizeInvalidator { - get { self[EpoxyIntrinsicContentSizeInvalidatorKey.self] } - set { self[EpoxyIntrinsicContentSizeInvalidatorKey.self] = newValue } - } + /// A means of invalidating the intrinsic content size of the parent `EpoxySwiftUIHostingView`. + public var epoxyIntrinsicContentSizeInvalidator: EpoxyIntrinsicContentSizeInvalidator { + get { self[EpoxyIntrinsicContentSizeInvalidatorKey.self] } + set { self[EpoxyIntrinsicContentSizeInvalidatorKey.self] = newValue } + } } // MARK: - EpoxyIntrinsicContentSizeInvalidatorKey private struct EpoxyIntrinsicContentSizeInvalidatorKey: EnvironmentKey { - static let defaultValue = EpoxyIntrinsicContentSizeInvalidator(invalidate: { }) + static let defaultValue = EpoxyIntrinsicContentSizeInvalidator(invalidate: { }) } diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingController.swift b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingController.swift index 82381928..3203775a 100644 --- a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingController.swift +++ b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingController.swift @@ -1,55 +1,45 @@ -// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112 - // Created by eric_horacek on 10/8/21. // Copyright © 2021 Airbnb Inc. All rights reserved. import SwiftUI #if !os(macOS) - - // MARK: - EpoxySwiftUIUIHostingController - - /// A `UIHostingController` that hosts SwiftUI views within an Epoxy container, e.g. an Epoxy - /// `CollectionView`. - /// - /// Exposed publicly to allow consumers to reason about these view controllers, e.g. to opt - /// collection view cells out of automated view controller impression tracking. - /// - /// - SeeAlso: `EpoxySwiftUIHostingView` - open class EpoxySwiftUIHostingController: UIHostingController { - // MARK: Lifecycle - - /// Creates a `UIHostingController` that optionally ignores the `safeAreaInsets` when laying out - /// its contained `RootView`. - public convenience init(rootView: Content, ignoreSafeArea: Bool) { - self.init(rootView: rootView) - - clearBackground() - - // We unfortunately need to call a private API to disable the safe area. We can also accomplish - // this by dynamically subclassing this view controller's view at runtime and overriding its - // `safeAreaInsets` property and returning `.zero`. An implementation of that logic is - // available in this file in the `2d28b3181cca50b89618b54836f7a9b6e36ea78e` commit if this API - // no longer functions in future SwiftUI versions. - _disableSafeArea = ignoreSafeArea - } - - // MARK: Open - - override open func viewDidLoad() { - super.viewDidLoad() - - clearBackground() - } - - // MARK: Internal - - func clearBackground() { - // A `UIHostingController` has a system background color by default as it's typically used in - // full-screen use cases. Since we're using this view controller to place SwiftUI views within - // other view controllers we default the background color to clear so we can see the views - // below, e.g. to draw highlight states in a `CollectionView`. - view.backgroundColor = .clear - } - } +// MARK: - EpoxySwiftUIUIHostingController + +/// A `UIHostingController` that hosts SwiftUI views within an Epoxy container, e.g. an Epoxy +/// `CollectionView`. +/// +/// Exposed publicly to allow consumers to reason about these view controllers, e.g. to opt +/// collection view cells out of automated view controller impression tracking. +/// +/// - SeeAlso: `EpoxySwiftUIHostingView` +open class EpoxySwiftUIHostingController: UIHostingController { + + // MARK: Lifecycle + + /// Creates a `UIHostingController` that optionally ignores the `safeAreaInsets` when laying out + /// its contained `RootView`. + public convenience init(rootView: Content, ignoreSafeArea: Bool) { + self.init(rootView: rootView) + + // We unfortunately need to call a private API to disable the safe area. We can also accomplish + // this by dynamically subclassing this view controller's view at runtime and overriding its + // `safeAreaInsets` property and returning `.zero`. An implementation of that logic is + // available in this file in the `2d28b3181cca50b89618b54836f7a9b6e36ea78e` commit if this API + // no longer functions in future SwiftUI versions. + _disableSafeArea = ignoreSafeArea + } + + // MARK: Open + + open override func viewDidLoad() { + super.viewDidLoad() + + // A `UIHostingController` has a system background color by default as it's typically used in + // full-screen use cases. Since we're using this view controller to place SwiftUI views within + // other view controllers we default the background color to clear so we can see the views + // below, e.g. to draw highlight states in a `CollectionView`. + view.backgroundColor = .clear + } +} #endif diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingView.swift b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingView.swift index 01e3f768..481a52d2 100644 --- a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingView.swift +++ b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingView.swift @@ -1,5 +1,3 @@ -// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112 - // Created by eric_horacek on 9/16/21. // Copyright © 2021 Airbnb Inc. All rights reserved. @@ -8,416 +6,437 @@ import SwiftUI #if !os(macOS) - // MARK: - SwiftUIHostingViewReuseBehavior - - /// The reuse behavior of an `EpoxySwiftUIHostingView`. - public enum SwiftUIHostingViewReuseBehavior: Hashable { - /// Instances of a `EpoxySwiftUIHostingView` with `RootView`s of same type can be reused within - /// the Epoxy container. - /// - /// This is the default reuse behavior. - case reusable - /// Instances of a `EpoxySwiftUIHostingView` with `RootView`s of same type can only reused within - /// the Epoxy container when they have identical `reuseID`s. - case unique(reuseID: AnyHashable) +// MARK: - SwiftUIHostingViewReuseBehavior + +/// The reuse behavior of an `EpoxySwiftUIHostingView`. +public enum SwiftUIHostingViewReuseBehavior: Hashable { + /// Instances of a `EpoxySwiftUIHostingView` with `RootView`s of same type can be reused within + /// the Epoxy container. + /// + /// This is the default reuse behavior. + case reusable + /// Instances of a `EpoxySwiftUIHostingView` with `RootView`s of same type can only reused within + /// the Epoxy container when they have identical `reuseID`s. + case unique(reuseID: AnyHashable) +} + +// MARK: - CallbackContextEpoxyModeled + +extension CallbackContextEpoxyModeled + where + Self: WillDisplayProviding & DidEndDisplayingProviding, + CallbackContext: ViewProviding & AnimatedProviding +{ + /// Updates the appearance state of a `EpoxySwiftUIHostingView` in coordination with the + /// `willDisplay` and `didEndDisplaying` callbacks of this `EpoxyableModel`. + /// + /// - Note: You should only need to call then from the implementation of a concrete + /// `EpoxyableModel` convenience vendor method, e.g. `SwiftUI.View.itemModel(…)`. + public func linkDisplayLifecycle() -> Self + where + CallbackContext.View == EpoxySwiftUIHostingView + { + willDisplay { context in + context.view.handleWillDisplay(animated: context.animated) } - - // MARK: - EpoxySwiftUIHostingView - - /// A `UIView` that hosts a SwiftUI view within an Epoxy container, e.g. an Epoxy `CollectionView`. - /// - /// Wraps an `EpoxySwiftUIHostingController` and adds it as a child view controller to the next - /// ancestor view controller in the hierarchy. - /// - /// There's a private API that accomplishes this same behavior without needing a `UIViewController`: - /// `_UIHostingView`, but we can't safely use it as 1) the behavior may change out from under us, 2) - /// the API is private and 3) the `_UIHostingView` doesn't not accept setting a new `View` instance. - /// - /// - SeeAlso: `EpoxySwiftUIHostingController` - public final class EpoxySwiftUIHostingView: UIView { - public struct Style: Hashable { - // MARK: Lifecycle - - public init( - reuseBehavior: SwiftUIHostingViewReuseBehavior, - initialContent: Content, - ignoreSafeArea: Bool = true - ) { - self.reuseBehavior = reuseBehavior - self.initialContent = initialContent - self.ignoreSafeArea = ignoreSafeArea - } - - // MARK: Public - - public var reuseBehavior: SwiftUIHostingViewReuseBehavior - public var initialContent: Content - public var ignoreSafeArea: Bool - - public static func == (lhs: Style, rhs: Style) -> Bool { - lhs.reuseBehavior == rhs.reuseBehavior - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(reuseBehavior) - } - } - - public struct Content: Equatable { - // MARK: Lifecycle - - public init(rootView: RootView, dataID: AnyHashable?) { - self.rootView = rootView - self.dataID = dataID - } - - // MARK: Public - - public var rootView: RootView - public var dataID: AnyHashable? - - public static func == (_: Content, _: Content) -> Bool { - // The content should never be equal since we need the `rootView` to be updated on every - // content change. - false - } - } - - // MARK: Lifecycle - - public init(style: Style) { - // Ignore the safe area to ensure the view isn't laid out incorrectly when being sized while - // overlapping the safe area. - epoxyContent = EpoxyHostingContent(rootView: style.initialContent.rootView) - viewController = EpoxySwiftUIHostingController( - rootView: .init(content: epoxyContent, environment: epoxyEnvironment), - ignoreSafeArea: style.ignoreSafeArea - ) - - dataID = style.initialContent.dataID ?? DefaultDataID.noneProvided as AnyHashable - - super.init(frame: .zero) - - epoxyEnvironment.intrinsicContentSizeInvalidator = .init(invalidate: { [weak self] in - self?.viewController.view.invalidateIntrinsicContentSize() - - // Inform the enclosing collection view that the size has changed, if we're contained in one, - // allowing the cell to resize. - // - // On iOS 16+, we could call `invalidateIntrinsicContentSize()` on the enclosing collection - // view cell instead, but that currently causes visual artifacts with `MagazineLayout`. The - // better long term fix is likely to switch to `UIHostingConfiguration` on iOS 16+ anyways. - if let enclosingCollectionView = self?.superview?.superview?.superview as? UICollectionView { - enclosingCollectionView.collectionViewLayout.invalidateLayout() - } - }) - layoutMargins = .zero - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Public - - override public func didMoveToWindow() { - super.didMoveToWindow() - - // Having our window set is an indicator that we should try adding our `viewController` as a - // child. We try this from a few other places to cover all of our bases. - addViewControllerIfNeededAndReady() - } - - override public func didMoveToSuperview() { - super.didMoveToSuperview() - - // Having our superview set is an indicator that we should try adding our `viewController` as a - // child. We try this from a few other places to cover all of our bases. - // - // Previously, we did not implement this function, and instead relied on `didMoveToWindow` being - // called to know when to attempt adding our `viewController` as a child. This resulted in a - // cell sizing issue, where the cell would return an estimated size. This was due to a timing - // issue with adding our `viewController` as a child. The order of events that caused the bug is - // as follows: - // 1. `collectionView(_:cellForItemAt:)` is called - // 2. An `EpoxySwiftUIHostingView` is created via `makeView()` - // 3. The hosting view is added as a subview of, and constrained to, the cell's `contentView` - // via a call to `setViewIfNeeded(view:)` - // 4. The hosting view's `didMoveToSuperview` function is called, but prior to this change, we - // did nothing in this function - // 5. We return from `collectionView(_:cellForItemAt:)` - // 6. `UICollectionView` calls the cell's `preferredLayoutAttributesFitting:` function, which - // returns an estimated size - // 7. The hosting view's `didMoveToWindow` function is called, and we finally add our - // `viewController` as a child - // 8. No additional sizing attempt is made by `UICollectionViewFlowLayout` or `MagazineLayout` - // (for some reason compositional layout recovers) - // - // A reliable repro case for this bug is the following setup: - // 1. Have a tab bar controller with two tabs - the first containing an Epoxy collection view, - // the second containing nothing - // 2. Have a reload function on the first view controller that sets one section with a few - // SwiftUI items (`Color.red.frame(width: 300, height: 300`).itemModel(dataID: ...)`) - // 3. Switch away from the tab containing the collection view - // 4. Call the reload function on the collection view on the tab that's no longer visible - // 4. Upon returning to the first tab, the collection view will contain incorrectly sized cells - addViewControllerIfNeededAndReady() - } - - public func setContent(_ content: Content, animated _: Bool) { - // This triggers a change in the observed `EpoxyHostingContent` object and allows the - // propagation of the SwiftUI transaction, instead of just replacing the `rootView`. - epoxyContent.rootView = content.rootView - dataID = content.dataID ?? DefaultDataID.noneProvided as AnyHashable - - // The view controller must be added to the view controller hierarchy to measure its content. - addViewControllerIfNeededAndReady() - - // 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() - } - - override public func layoutMarginsDidChange() { - super.layoutMarginsDidChange() - - let margins = layoutMargins - switch effectiveUserInterfaceLayoutDirection { - case .rightToLeft: - epoxyEnvironment.layoutMargins = .init( - top: margins.top, - leading: margins.right, - bottom: margins.bottom, - trailing: margins.left - ) - case .leftToRight: - fallthrough - @unknown default: - epoxyEnvironment.layoutMargins = .init( - top: margins.top, - leading: margins.left, - bottom: margins.bottom, - trailing: margins.right - ) - } - - // Allow the layout margins update to fully propagate through to the SwiftUI View before - // invalidating the layout. - DispatchQueue.main.async { - self.viewController.view.invalidateIntrinsicContentSize() - } - } - - public func handleWillDisplay(animated: Bool) { - guard state != .appeared, window != nil else { return } - transition(to: .appearing(animated: animated)) - transition(to: .appeared) - } - - public func handleDidEndDisplaying(animated: Bool) { - guard state != .disappeared else { return } - transition(to: .disappearing(animated: animated)) - transition(to: .disappeared) - } - - // MARK: Private - - private let viewController: EpoxySwiftUIHostingController> - private let epoxyContent: EpoxyHostingContent - private let epoxyEnvironment = EpoxyHostingEnvironment() - private var dataID: AnyHashable - private var state: AppearanceState = .disappeared - - /// Updates the appearance state of the `viewController`. - private func transition(to state: AppearanceState) { - guard state != self.state else { return } - - // See "Handling View-Related Notifications" section for the state machine diagram. - // https://developer.apple.com/documentation/uikit/uiviewcontroller - switch (to: state, from: self.state) { - case (to: .appearing(let animated), from: .disappeared): - viewController.beginAppearanceTransition(true, animated: animated) - addViewControllerIfNeededAndReady() - case (to: .disappearing(let animated), from: .appeared): - viewController.beginAppearanceTransition(false, animated: animated) - case (to: .disappeared, from: .disappearing): - removeViewControllerIfNeeded() - case (to: .appeared, from: .appearing): - viewController.endAppearanceTransition() - case (to: .disappeared, from: .appeared): - viewController.beginAppearanceTransition(false, animated: true) - removeViewControllerIfNeeded() - case (to: .appeared, from: .disappearing(let animated)): - viewController.beginAppearanceTransition(true, animated: animated) - viewController.endAppearanceTransition() - case (to: .disappeared, from: .appearing(let animated)): - viewController.beginAppearanceTransition(false, animated: animated) - removeViewControllerIfNeeded() - case (to: .appeared, from: .disappeared): - viewController.beginAppearanceTransition(true, animated: false) - addViewControllerIfNeededAndReady() - viewController.endAppearanceTransition() - case (to: .appearing(let animated), from: .appeared): - viewController.beginAppearanceTransition(false, animated: animated) - viewController.beginAppearanceTransition(true, animated: animated) - case (to: .appearing(let animated), from: .disappearing): - viewController.beginAppearanceTransition(true, animated: animated) - case (to: .disappearing(let animated), from: .disappeared): - viewController.beginAppearanceTransition(true, animated: animated) - addViewControllerIfNeededAndReady() - viewController.beginAppearanceTransition(false, animated: animated) - case (to: .disappearing(let animated), from: .appearing): - viewController.beginAppearanceTransition(false, animated: animated) - case (to: .appearing, from: .appearing), - (to: .appeared, from: .appeared), - (to: .disappearing, from: .disappearing), - (to: .disappeared, from: .disappeared): - // This should never happen since we guard on identical states. - assertionFailure("Impossible state change from \(self.state) to \(state)") - } - - self.state = state - } - - private func addViewControllerIfNeededAndReady() { - guard let superview else { - // If our superview is nil, we're too early and have no chance of finding a view controller - // up the responder chain. - return - } - - // This isn't great, and means that we're going to add this view controller as a child view - // controller of a view controller somewhere else in the hierarchy, which the author of that - // view controller may not be expecting. However there's not really a better pathway forward - // here without requiring a view controller instance to be passed all the way through, which is - // both burdensome and error-prone. - let nextViewController = superview.next(UIViewController.self) - - if nextViewController == nil, window == nil { - // If the view controller is nil, but our window is also nil, we're a bit too early. It's - // possible to find a view controller up the responder chain without having a window, which is - // why we don't guard or assert on having a window. - return - } - - guard let nextViewController else { - // One of the two previous early returns should have prevented us from getting here. - assertionFailure( - """ - Unable to add a UIHostingController view, could not locate a UIViewController in the \ - responder chain for view with ID \(dataID) of type \(RootView.self). - """) - return - } - - guard viewController.parent !== nextViewController else { return } - - // If in a different parent, we need to first remove from it before we add. - if viewController.parent != nil { - removeViewControllerIfNeeded() - } - - addViewController(to: nextViewController) - - state = .appeared - } - - private func addViewController(to parent: UIViewController) { - viewController.willMove(toParent: parent) - - parent.addChild(viewController) - - addSubview(viewController.view) - - viewController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - viewController.view.leadingAnchor.constraint(equalTo: leadingAnchor), - // Pining the hosting view controller to layoutMarginsGuide ensures the content respects the top safe area - // when installed inside a `TopBarContainer` - viewController.view.topAnchor.constraint(equalTo: topAnchor), - viewController.view.trailingAnchor.constraint(equalTo: trailingAnchor), - // Pining the hosting view controller to layoutMarginsGuide ensures the content respects the bottom safe area - // when installed inside a `BottomBarContainer` - viewController.view.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - - viewController.didMove(toParent: parent) - } - - private func removeViewControllerIfNeeded() { - guard viewController.parent != nil else { return } - - viewController.willMove(toParent: nil) - viewController.view.removeFromSuperview() - viewController.removeFromParent() - viewController.didMove(toParent: nil) - } + .didEndDisplaying { context in + context.view.handleDidEndDisplaying(animated: context.animated) + } + } +} + +// MARK: - EpoxySwiftUIHostingView + +/// A `UIView` that hosts a SwiftUI view within an Epoxy container, e.g. an Epoxy `CollectionView`. +/// +/// Wraps an `EpoxySwiftUIHostingController` and adds it as a child view controller to the next +/// ancestor view controller in the hierarchy. +/// +/// There's a private API that accomplishes this same behavior without needing a `UIViewController`: +/// `_UIHostingView`, but we can't safely use it as 1) the behavior may change out from under us, 2) +/// 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 { + + // MARK: Lifecycle + + public init(style: Style) { + // Ignore the safe area to ensure the view isn't laid out incorrectly when being sized while + // overlapping the safe area. + epoxyContent = EpoxyHostingContent(rootView: style.initialContent.rootView) + viewController = EpoxySwiftUIHostingController( + rootView: .init(content: epoxyContent, environment: epoxyEnvironment), + ignoreSafeArea: style.ignoreSafeArea) + + dataID = style.initialContent.dataID ?? DefaultDataID.noneProvided as AnyHashable + + super.init(frame: .zero) + + epoxyEnvironment.intrinsicContentSizeInvalidator = .init(invalidate: { [weak self] in + self?.viewController.view.invalidateIntrinsicContentSize() + + // Inform the enclosing collection view that the size has changed, if we're contained in one, + // allowing the cell to resize. + // + // On iOS 16+, we could call `invalidateIntrinsicContentSize()` on the enclosing collection + // view cell instead, but that currently causes visual artifacts with `MagazineLayout`. The + // better long term fix is likely to switch to `UIHostingConfiguration` on iOS 16+ anyways. + if let enclosingCollectionView = self?.superview?.superview?.superview as? UICollectionView { + enclosingCollectionView.collectionViewLayout.invalidateLayout() + } + }) + layoutMargins = .zero + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Public + + public struct Style: Hashable { + + // MARK: Lifecycle + + public init( + reuseBehavior: SwiftUIHostingViewReuseBehavior, + initialContent: Content, + ignoreSafeArea: Bool = true) + { + self.reuseBehavior = reuseBehavior + self.initialContent = initialContent + self.ignoreSafeArea = ignoreSafeArea } - // MARK: - AppearanceState + // MARK: Public - /// The appearance state of a `EpoxySwiftUIHostingController` contained within a - /// `EpoxySwiftUIHostingView`. - private enum AppearanceState: Equatable { - case appearing(animated: Bool) - case appeared - case disappearing(animated: Bool) - case disappeared + public var reuseBehavior: SwiftUIHostingViewReuseBehavior + public var initialContent: Content + public var ignoreSafeArea: Bool + + public static func == (lhs: Style, rhs: Style) -> Bool { + lhs.reuseBehavior == rhs.reuseBehavior } - // MARK: - UIResponder + public func hash(into hasher: inout Hasher) { + hasher.combine(reuseBehavior) + } + } - extension UIResponder { - /// Recursively traverses the responder chain upwards from this responder to its next responder - /// until the a responder of the given type is located, else returns `nil`. - @nonobjc - fileprivate func next(_ type: ResponderType.Type) -> ResponderType? { - self as? ResponderType ?? next?.next(type) - } + public struct Content: Equatable { + public init(rootView: RootView, dataID: AnyHashable?) { + self.rootView = rootView + self.dataID = dataID } - // MARK: - EpoxyHostingContent + public var rootView: RootView + public var dataID: AnyHashable? - /// The object that is used to communicate changes in the root view to the - /// `EpoxySwiftUIHostingController`. - final class EpoxyHostingContent: ObservableObject { - // MARK: Lifecycle + public static func == (_: Content, _: Content) -> Bool { + // The content should never be equal since we need the `rootView` to be updated on every + // content change. + false + } + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + + // Having our window set is an indicator that we should try adding our `viewController` as a + // child. We try this from a few other places to cover all of our bases. + addViewControllerIfNeededAndReady() + } + + public override func didMoveToSuperview() { + super.didMoveToSuperview() + + // Having our superview set is an indicator that we should try adding our `viewController` as a + // child. We try this from a few other places to cover all of our bases. + // + // Previously, we did not implement this function, and instead relied on `didMoveToWindow` being + // called to know when to attempt adding our `viewController` as a child. This resulted in a + // cell sizing issue, where the cell would return an estimated size. This was due to a timing + // issue with adding our `viewController` as a child. The order of events that caused the bug is + // as follows: + // 1. `collectionView(_:cellForItemAt:)` is called + // 2. An `EpoxySwiftUIHostingView` is created via `makeView()` + // 3. The hosting view is added as a subview of, and constrained to, the cell's `contentView` + // via a call to `setViewIfNeeded(view:)` + // 4. The hosting view's `didMoveToSuperview` function is called, but prior to this change, we + // did nothing in this function + // 5. We return from `collectionView(_:cellForItemAt:)` + // 6. `UICollectionView` calls the cell's `preferredLayoutAttributesFitting:` function, which + // returns an estimated size + // 7. The hosting view's `didMoveToWindow` function is called, and we finally add our + // `viewController` as a child + // 8. No additional sizing attempt is made by `UICollectionViewFlowLayout` or `MagazineLayout` + // (for some reason compositional layout recovers) + // + // A reliable repro case for this bug is the following setup: + // 1. Have a tab bar controller with two tabs - the first containing an Epoxy collection view, + // the second containing nothing + // 2. Have a reload function on the first view controller that sets one section with a few + // SwiftUI items (`Color.red.frame(width: 300, height: 300`).itemModel(dataID: ...)`) + // 3. Switch away from the tab containing the collection view + // 4. Call the reload function on the collection view on the tab that's no longer visible + // 4. Upon returning to the first tab, the collection view will contain incorrectly sized cells + addViewControllerIfNeededAndReady() + } + + public func setContent(_ content: Content, animated _: Bool) { + // This triggers a change in the observed `EpoxyHostingContent` object and allows the + // propagation of the SwiftUI transaction, instead of just replacing the `rootView`. + epoxyContent.rootView = content.rootView + dataID = content.dataID ?? DefaultDataID.noneProvided as AnyHashable + + // The view controller must be added to the view controller hierarchy to measure its content. + addViewControllerIfNeededAndReady() + + // 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() + } + + public override func layoutMarginsDidChange() { + super.layoutMarginsDidChange() + + let margins = layoutMargins + switch effectiveUserInterfaceLayoutDirection { + case .rightToLeft: + epoxyEnvironment.layoutMargins = .init( + top: margins.top, + leading: margins.right, + bottom: margins.bottom, + trailing: margins.left) + case .leftToRight: + fallthrough + @unknown default: + epoxyEnvironment.layoutMargins = .init( + top: margins.top, + leading: margins.left, + bottom: margins.bottom, + trailing: margins.right) + } - init(rootView: RootView) { - _rootView = .init(wrappedValue: rootView) - } + // Allow the layout margins update to fully propagate through to the SwiftUI View before + // invalidating the layout. + DispatchQueue.main.async { + self.viewController.view.invalidateIntrinsicContentSize() + } + } + + public func handleWillDisplay(animated: Bool) { + guard state != .appeared, window != nil else { return } + transition(to: .appearing(animated: animated)) + transition(to: .appeared) + } + + public func handleDidEndDisplaying(animated: Bool) { + guard state != .disappeared else { return } + transition(to: .disappearing(animated: animated)) + transition(to: .disappeared) + } + + // MARK: Private + + private let viewController: EpoxySwiftUIHostingController> + private let epoxyContent: EpoxyHostingContent + private let epoxyEnvironment = EpoxyHostingEnvironment() + private var dataID: AnyHashable + private var state: AppearanceState = .disappeared + + /// Updates the appearance state of the `viewController`. + private func transition(to state: AppearanceState) { + guard state != self.state else { return } + + // See "Handling View-Related Notifications" section for the state machine diagram. + // https://developer.apple.com/documentation/uikit/uiviewcontroller + switch (to: state, from: self.state) { + case (to: .appearing(let animated), from: .disappeared): + viewController.beginAppearanceTransition(true, animated: animated) + addViewControllerIfNeededAndReady() + case (to: .disappearing(let animated), from: .appeared): + viewController.beginAppearanceTransition(false, animated: animated) + case (to: .disappeared, from: .disappearing): + removeViewControllerIfNeeded() + case (to: .appeared, from: .appearing): + viewController.endAppearanceTransition() + case (to: .disappeared, from: .appeared): + viewController.beginAppearanceTransition(false, animated: true) + removeViewControllerIfNeeded() + case (to: .appeared, from: .disappearing(let animated)): + viewController.beginAppearanceTransition(true, animated: animated) + viewController.endAppearanceTransition() + case (to: .disappeared, from: .appearing(let animated)): + viewController.beginAppearanceTransition(false, animated: animated) + removeViewControllerIfNeeded() + case (to: .appeared, from: .disappeared): + viewController.beginAppearanceTransition(true, animated: false) + addViewControllerIfNeededAndReady() + viewController.endAppearanceTransition() + case (to: .appearing(let animated), from: .appeared): + viewController.beginAppearanceTransition(false, animated: animated) + viewController.beginAppearanceTransition(true, animated: animated) + case (to: .appearing(let animated), from: .disappearing): + viewController.beginAppearanceTransition(true, animated: animated) + case (to: .disappearing(let animated), from: .disappeared): + viewController.beginAppearanceTransition(true, animated: animated) + addViewControllerIfNeededAndReady() + viewController.beginAppearanceTransition(false, animated: animated) + case (to: .disappearing(let animated), from: .appearing): + viewController.beginAppearanceTransition(false, animated: animated) + case (to: .appearing, from: .appearing), + (to: .appeared, from: .appeared), + (to: .disappearing, from: .disappearing), + (to: .disappeared, from: .disappeared): + // This should never happen since we guard on identical states. + EpoxyLogger.shared.assertionFailure("Impossible state change from \(self.state) to \(state)") + } - // MARK: Internal + self.state = state + } - @Published var rootView: RootView + private func addViewControllerIfNeededAndReady() { + guard let superview = superview else { + // If our superview is nil, we're too early and have no chance of finding a view controller + // up the responder chain. + return } - // MARK: - EpoxyHostingEnvironment - - /// The object that is used to communicate values to SwiftUI views within an - /// `EpoxySwiftUIHostingController`, e.g. layout margins. - final class EpoxyHostingEnvironment: ObservableObject { - @Published var layoutMargins = EdgeInsets() - @Published var intrinsicContentSizeInvalidator = EpoxyIntrinsicContentSizeInvalidator(invalidate: { }) + // This isn't great, and means that we're going to add this view controller as a child view + // controller of a view controller somewhere else in the hierarchy, which the author of that + // view controller may not be expecting. However there's not really a better pathway forward + // here without requiring a view controller instance to be passed all the way through, which is + // both burdensome and error-prone. + let nextViewController = superview.next(UIViewController.self) + + if nextViewController == nil, window == nil { + // If the view controller is nil, but our window is also nil, we're a bit too early. It's + // possible to find a view controller up the responder chain without having a window, which is + // why we don't guard or assert on having a window. + return } - // MARK: - EpoxyHostingWrapper + guard let nextViewController = nextViewController else { + // One of the two previous early returns should have prevented us from getting here. + EpoxyLogger.shared.assertionFailure( + """ + Unable to add a UIHostingController view, could not locate a UIViewController in the \ + responder chain for view with ID \(dataID) of type \(RootView.self). + """) + return + } - /// The wrapper view that is used to communicate values to SwiftUI views within an - /// `EpoxySwiftUIHostingController`, e.g. layout margins. - struct EpoxyHostingWrapper: View { - @ObservedObject var content: EpoxyHostingContent - @ObservedObject var environment: EpoxyHostingEnvironment + guard viewController.parent !== nextViewController else { return } - var body: some View { - content.rootView - .environment(\.epoxyLayoutMargins, environment.layoutMargins) - .environment(\.epoxyIntrinsicContentSizeInvalidator, environment.intrinsicContentSizeInvalidator) - } + // If in a different parent, we need to first remove from it before we add. + if viewController.parent != nil { + removeViewControllerIfNeeded() } + addViewController(to: nextViewController) + + state = .appeared + } + + private func addViewController(to parent: UIViewController) { + viewController.willMove(toParent: parent) + + parent.addChild(viewController) + + addSubview(viewController.view) + + viewController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + viewController.view.leadingAnchor.constraint(equalTo: leadingAnchor), + // Pining the hosting view controller to layoutMarginsGuide ensures the content respects the top safe area + // when installed inside a `TopBarContainer` + viewController.view.topAnchor.constraint(equalTo: topAnchor), + viewController.view.trailingAnchor.constraint(equalTo: trailingAnchor), + // Pining the hosting view controller to layoutMarginsGuide ensures the content respects the bottom safe area + // when installed inside a `BottomBarContainer` + viewController.view.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + viewController.didMove(toParent: parent) + } + + private func removeViewControllerIfNeeded() { + guard viewController.parent != nil else { return } + + viewController.willMove(toParent: nil) + viewController.view.removeFromSuperview() + viewController.removeFromParent() + viewController.didMove(toParent: nil) + } +} + +// MARK: - AppearanceState + +/// The appearance state of a `EpoxySwiftUIHostingController` contained within a +/// `EpoxySwiftUIHostingView`. +private enum AppearanceState: Equatable { + case appearing(animated: Bool) + case appeared + case disappearing(animated: Bool) + case disappeared +} + +// MARK: - UIResponder + +extension UIResponder { + /// Recursively traverses the responder chain upwards from this responder to its next responder + /// until the a responder of the given type is located, else returns `nil`. + @nonobjc + fileprivate func next(_ type: ResponderType.Type) -> ResponderType? { + self as? ResponderType ?? next?.next(type) + } +} + +// MARK: - EpoxyHostingContent + +/// The object that is used to communicate changes in the root view to the +/// `EpoxySwiftUIHostingController`. +final class EpoxyHostingContent: ObservableObject { + + // MARK: Lifecycle + + init(rootView: RootView) { + _rootView = .init(wrappedValue: rootView) + } + + // MARK: Internal + + @Published var rootView: RootView +} + +// MARK: - EpoxyHostingEnvironment + +/// The object that is used to communicate values to SwiftUI views within an +/// `EpoxySwiftUIHostingController`, e.g. layout margins. +final class EpoxyHostingEnvironment: ObservableObject { + @Published var layoutMargins = EdgeInsets() + @Published var intrinsicContentSizeInvalidator = EpoxyIntrinsicContentSizeInvalidator(invalidate: { }) +} + +// MARK: - EpoxyHostingWrapper + +/// The wrapper view that is used to communicate values to SwiftUI views within an +/// `EpoxySwiftUIHostingController`, e.g. layout margins. +struct EpoxyHostingWrapper: View { + @ObservedObject var content: EpoxyHostingContent + @ObservedObject var environment: EpoxyHostingEnvironment + + var body: some View { + content.rootView + .environment(\.epoxyLayoutMargins, environment.layoutMargins) + .environment(\.epoxyIntrinsicContentSizeInvalidator, environment.intrinsicContentSizeInvalidator) + } +} + #endif diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUILayoutMargins.swift b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUILayoutMargins.swift index 86c85b83..b8c965bf 100644 --- a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUILayoutMargins.swift +++ b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUILayoutMargins.swift @@ -1,5 +1,3 @@ -// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112 - // Created by eric_horacek on 10/8/21. // Copyright © 2021 Airbnb Inc. All rights reserved. @@ -8,32 +6,32 @@ import SwiftUI // MARK: - View extension View { - /// Applies the layout margins from the parent `EpoxySwiftUIHostingView` to this `View`, if there - /// are any. - /// - /// Can be used to have a background in SwiftUI underlap the safe area within a bar installer, for - /// example. - /// - /// These margins are propagated via the `EnvironmentValues.epoxyLayoutMargins`. - public func epoxyLayoutMargins() -> some View { - modifier(EpoxyLayoutMarginsPadding()) - } + /// Applies the layout margins from the parent `EpoxySwiftUIHostingView` to this `View`, if there + /// are any. + /// + /// Can be used to have a background in SwiftUI underlap the safe area within a bar installer, for + /// example. + /// + /// These margins are propagated via the `EnvironmentValues.epoxyLayoutMargins`. + public func epoxyLayoutMargins() -> some View { + modifier(EpoxyLayoutMarginsPadding()) + } } // MARK: - EnvironmentValues extension EnvironmentValues { - /// The layout margins of the parent `EpoxySwiftUIHostingView`, else zero if there is none. - public var epoxyLayoutMargins: EdgeInsets { - get { self[EpoxyLayoutMarginsKey.self] } - set { self[EpoxyLayoutMarginsKey.self] = newValue } - } + /// The layout margins of the parent `EpoxySwiftUIHostingView`, else zero if there is none. + public var epoxyLayoutMargins: EdgeInsets { + get { self[EpoxyLayoutMarginsKey.self] } + set { self[EpoxyLayoutMarginsKey.self] = newValue } + } } // MARK: - EpoxyLayoutMarginsKey private struct EpoxyLayoutMarginsKey: EnvironmentKey { - static let defaultValue = EdgeInsets() + static let defaultValue = EdgeInsets() } // MARK: - EpoxyLayoutMarginsPadding @@ -41,9 +39,9 @@ private struct EpoxyLayoutMarginsKey: EnvironmentKey { /// A view modifier that applies the layout margins from an enclosing `EpoxySwiftUIHostingView` to /// the modified `View`. private struct EpoxyLayoutMarginsPadding: ViewModifier { - @Environment(\.epoxyLayoutMargins) var epoxyLayoutMargins + @Environment(\.epoxyLayoutMargins) var epoxyLayoutMargins - func body(content: Content) -> some View { - content.padding(epoxyLayoutMargins) - } + func body(content: Content) -> some View { + content.padding(epoxyLayoutMargins) + } } diff --git a/UI/UIx/SwiftUI/Epoxy/README.md b/UI/UIx/SwiftUI/Epoxy/README.md new file mode 100644 index 00000000..93024dd0 --- /dev/null +++ b/UI/UIx/SwiftUI/Epoxy/README.md @@ -0,0 +1,19 @@ +# Epoxy-ios Fork + +This repository contains selected files from the Epoxy iOS project, a suite of Swift libraries for building complex and reactive user interfaces on iOS. + +## Source + +The files in this directory were copied from the official Epoxy iOS project by Airbnb. The specific commit used for this fork can be found here: [Epoxy iOS Commit](https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112). + +## License + +Epoxy is released under the Apache License 2.0. For the full license text, see [LICENSE](https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112/LICENSE). + +## Usage + +This fork includes select components from the Epoxy iOS project. It is intended for specific use cases where only certain functionalities are required, rather than the full library. + +## Acknowledgements + +Special thanks to Airbnb and the contributors of the Epoxy iOS project for providing a robust and flexible foundation for iOS UI development. diff --git a/UI/UIx/SwiftUI/Epoxy/_Compatibility.swift b/UI/UIx/SwiftUI/Epoxy/_Compatibility.swift new file mode 100644 index 00000000..ab6121e7 --- /dev/null +++ b/UI/UIx/SwiftUI/Epoxy/_Compatibility.swift @@ -0,0 +1,62 @@ +// +// Compatibility.swift +// +// +// Created by Mohamed Afifi on 2024-01-18. +// + +import VLogging +import Logging + +struct EpoxyLogger { + static let shared = EpoxyLogger() + + func warn(_ message: @autoclosure () -> Logger.Message) { + logger.warning(message()) + } + + @inlinable public func assertionFailure(_ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line) { + Swift.assertionFailure(message(), file: file, line: line) + } +} + +public protocol EpoxyModeled { + subscript(property: EpoxyModelProperty) -> Property { get set } + func copy(updating property: EpoxyModelProperty, to value: Value) -> Self +} + +public struct EpoxyModelProperty { + enum UpdateStrategy { + case replace + } + + init( + keyPath: KeyPath, + defaultValue: @escaping @autoclosure () -> Value, + updateStrategy: UpdateStrategy) + { + } +} + +protocol EpoxyableView {} + +public protocol CallbackContextEpoxyModeled { + associatedtype CallbackContext: ViewProviding +} + +public protocol WillDisplayProviding: CallbackContextEpoxyModeled { + func willDisplay(_ value: (CallbackContext) -> Void) -> Self +} + +public protocol DidEndDisplayingProviding: CallbackContextEpoxyModeled { + func didEndDisplaying(_ value: (CallbackContext) -> Void) -> Self +} + +public protocol ViewProviding { + associatedtype View + var view: View { get } +} + +public protocol AnimatedProviding { + var animated: Bool { get } +}