diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ddcd0f2..ff4767e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 approach to resolve an issue that could cause collection view cells to layout with unexpected dimensions - Made new layout-based SwiftUI cell rendering option the default. -- Fixed an issue where a UIKit view bridged to SwiftUI that wraps would always take up the proposed - size instead of its intrinsic width. ## [0.10.0](https://github.com/airbnb/epoxy-ios/compare/0.9.0...0.10.0) - 2023-06-29 diff --git a/Example/EpoxyExample/ViewControllers/SwiftUI/EpoxyInSwiftUISizingStrategiesViewController.swift b/Example/EpoxyExample/ViewControllers/SwiftUI/EpoxyInSwiftUISizingStrategiesViewController.swift index 26e2b3fa..d353457c 100644 --- a/Example/EpoxyExample/ViewControllers/SwiftUI/EpoxyInSwiftUISizingStrategiesViewController.swift +++ b/Example/EpoxyExample/ViewControllers/SwiftUI/EpoxyInSwiftUISizingStrategiesViewController.swift @@ -64,7 +64,6 @@ extension SwiftUIMeasurementContainerStrategy: Identifiable, CaseIterable { public static var allCases: [SwiftUIMeasurementContainerStrategy] = [ .automatic, .proposed, - .intrinsicHeightProposedOrIntrinsicWidth, .intrinsicHeightProposedWidth, .intrinsicWidthProposedHeight, .intrinsic, @@ -82,8 +81,6 @@ extension SwiftUIMeasurementContainerStrategy: Identifiable, CaseIterable { return "Automatic" case .proposed: return "Proposed" - case .intrinsicHeightProposedOrIntrinsicWidth: - return "Intrinsic Height, Proposed Width or Intrinsic Width" case .intrinsicHeightProposedWidth: return "Intrinsic Height, Proposed Width" case .intrinsicWidthProposedHeight: diff --git a/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift b/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift index bda29027..ed4fdc7c 100644 --- a/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift +++ b/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift @@ -56,16 +56,13 @@ extension MeasuringViewRepresentable { // Creates a `CGSize` by replacing `nil`s with `UIView.noIntrinsicMetric` uiView.proposedSize = .init( - width: ( - children.first { $0.label == "width" }? - .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue, - height: ( - children.first { $0.label == "height" }? - .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue) + width: children.first { $0.label == "width" }?.value as? CGFloat ?? ViewType.noIntrinsicMetric, + height: children.first { $0.label == "height" }?.value as? CGFloat ?? ViewType.noIntrinsicMetric) + size = uiView.measuredFittingSize } - #if swift(>=5.7.1) // Proxy check for being built with the iOS 15 SDK + #if swift(>=5.7) // Proxy check for being built with the iOS 15 SDK @available(iOS 16.0, tvOS 16.0, macOS 13.0, *) public func sizeThatFits( _ proposal: ProposedViewSize, @@ -74,7 +71,12 @@ extension MeasuringViewRepresentable { -> CGSize? { uiView.strategy = sizing - uiView.proposedSize = proposal.viewTypeValue + + // Creates a size by replacing `nil`s with `UIView.noIntrinsicMetric` + uiView.proposedSize = .init( + width: proposal.width ?? ViewType.noIntrinsicMetric, + height: proposal.height ?? ViewType.noIntrinsicMetric) + return uiView.measuredFittingSize } #endif @@ -89,14 +91,14 @@ extension MeasuringViewRepresentable { nsView: NSViewType) { nsView.strategy = sizing + let children = Mirror(reflecting: proposedSize).children + + // Creates a `CGSize` by replacing `nil`s with `UIView.noIntrinsicMetric` nsView.proposedSize = .init( - width: ( - children.first { $0.label == "width" }? - .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue, - height: ( - children.first { $0.label == "height" }? - .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue) + width: children.first { $0.label == "width" }?.value as? CGFloat ?? ViewType.noIntrinsicMetric, + height: children.first { $0.label == "height" }?.value as? CGFloat ?? ViewType.noIntrinsicMetric) + size = nsView.measuredFittingSize } @@ -110,38 +112,14 @@ extension MeasuringViewRepresentable { -> CGSize? { nsView.strategy = sizing - nsView.proposedSize = proposal.viewTypeValue + + // Creates a size by replacing `nil`s with `UIView.noIntrinsicMetric` + nsView.proposedSize = .init( + width: proposal.width ?? ViewType.noIntrinsicMetric, + height: proposal.height ?? ViewType.noIntrinsicMetric) + return nsView.measuredFittingSize } #endif } #endif - -#if swift(>=5.7.1) // Proxy check for being built with the iOS 15 SDK -@available(iOS 16.0, tvOS 16.0, macOS 13.0, *) -extension ProposedViewSize { - /// Creates a size suitable for the current platform's view building framework by capping infinite values to a significantly large value and - /// replacing `nil`s with `UIView.noIntrinsicMetric` - var viewTypeValue: CGSize { - .init( - width: width?.constraintSafeValue ?? ViewType.noIntrinsicMetric, - height: height?.constraintSafeValue ?? ViewType.noIntrinsicMetric) - } -} - -#endif - -extension CGFloat { - static var maxConstraintValue: CGFloat { - // On iOS 15 and below, configuring an auto layout constraint with the constant - // `.greatestFiniteMagnitude` exceeds an internal limit and logs an exception to console. To - // avoid, we use a significantly large value. - 1_000_000 - } - - /// Returns a value suitable for configuring auto layout constraints - var constraintSafeValue: CGFloat { - isInfinite ? .maxConstraintValue : self - } - -} diff --git a/Sources/EpoxyCore/SwiftUI/LayoutUtilities/SwiftUIMeasurementContainer.swift b/Sources/EpoxyCore/SwiftUI/LayoutUtilities/SwiftUIMeasurementContainer.swift index eb6b329d..8730410f 100644 --- a/Sources/EpoxyCore/SwiftUI/LayoutUtilities/SwiftUIMeasurementContainer.swift +++ b/Sources/EpoxyCore/SwiftUI/LayoutUtilities/SwiftUIMeasurementContainer.swift @@ -1,11 +1,6 @@ // Created by Bryn Bodayle on 1/24/22. // Copyright © 2022 Airbnb Inc. All rights reserved. -#if os(iOS) || os(tvOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif import SwiftUI // MARK: - SwiftUIMeasurementContainer @@ -78,10 +73,8 @@ public final class SwiftUIMeasurementContainer: ViewType { public var proposedSize = CGSize.noIntrinsicMetric { didSet { guard oldValue != proposedSize else { return } - // The proposed size is only used by the measurement, so just re-measure. _measuredFittingSize = nil - setNeedsUpdateConstraintsForPlatform() } } @@ -135,11 +128,6 @@ public final class SwiftUIMeasurementContainer: ViewType { _measuredFittingSize = nil } - public override func updateConstraints() { - updateSizeConstraints() - super.updateConstraints() - } - // MARK: Private /// The most recently measured intrinsic content size of the `uiView`, else `noIntrinsicMetric` if @@ -152,21 +140,14 @@ public final class SwiftUIMeasurementContainer: ViewType { /// The bounds size at the time of the latest measurement. private var latestMeasurementBoundsSize: CGSize? - private var topConstraint: NSLayoutConstraint? - private var leadingConstraint: NSLayoutConstraint? - private var maxWidthConstraint: NSLayoutConstraint? - private var fixedWidthConstraint: NSLayoutConstraint? - private var fixedHeightConstraint: NSLayoutConstraint? + /// The most recently updated set of constraints constraining `uiView` to `self`. + private var uiViewConstraints = [NSLayoutConstraint.Attribute: NSLayoutConstraint]() /// The cached `resolvedStrategy` to prevent unnecessary re-measurements. private var _resolvedStrategy: ResolvedSwiftUIMeasurementContainerStrategy? /// The cached `measuredFittingSize` to prevent unnecessary re-measurements. - private var _measuredFittingSize: CGSize? { - didSet { - setNeedsUpdateConstraintsForPlatform() - } - } + private var _measuredFittingSize: CGSize? /// The resolved measurement strategy. private var resolvedStrategy: ResolvedSwiftUIMeasurementContainerStrategy { @@ -177,10 +158,15 @@ public final class SwiftUIMeasurementContainer: ViewType { let resolved: ResolvedSwiftUIMeasurementContainerStrategy switch strategy { case .automatic: - if content.containsDoubleLayoutPassSubviews() { - resolved = .intrinsicHeightProposedOrIntrinsicWidth + // Perform an intrinsic size measurement pass, which gives us valid values for + // `UILabel.preferredMaxLayoutWidth`. + let intrinsicSize = content.systemLayoutFittingIntrinsicSize() + + // If the view has a intrinsic width and contains a double layout pass subview, give it the + // proposed width to allow the label content to gracefully wrap to multiple lines. + if intrinsicSize.width > 0, content.containsDoubleLayoutPassSubviews() { + resolved = .intrinsicHeightProposedWidth } else { - let intrinsicSize = content.systemLayoutFittingIntrinsicSize() let zero = CGFloat(0) switch (width: intrinsicSize.width, height: intrinsicSize.height) { case (width: ...zero, height: ...zero): @@ -199,8 +185,6 @@ public final class SwiftUIMeasurementContainer: ViewType { resolved = .intrinsicHeightProposedWidth case .intrinsicWidthProposedHeight: resolved = .intrinsicWidthProposedHeight - case .intrinsicHeightProposedOrIntrinsicWidth: - resolved = .intrinsicHeightProposedOrIntrinsicWidth case .intrinsic: resolved = .intrinsic(content.systemLayoutFittingIntrinsicSize()) } @@ -211,94 +195,57 @@ public final class SwiftUIMeasurementContainer: ViewType { private func setUpConstraints() { content.translatesAutoresizingMaskIntoConstraints = false - let oldConstraints = [ - leadingConstraint, - topConstraint, - maxWidthConstraint, - fixedWidthConstraint, - fixedHeightConstraint, + let leading = content.leadingAnchor.constraint(equalTo: leadingAnchor) + let top = content.topAnchor.constraint(equalTo: topAnchor) + let trailing = content.trailingAnchor.constraint(equalTo: trailingAnchor) + let bottom = content.bottomAnchor.constraint(equalTo: bottomAnchor) + let newConstraints: [NSLayoutConstraint.Attribute: NSLayoutConstraint] = [ + .leading: leading, .top: top, .trailing: trailing, .bottom: bottom, ] - .compactMap { $0 } - NSLayoutConstraint.deactivate(oldConstraints) + // Start with the lowest priority constraints so we aren't measuring the view too early, the + // priorities will be updated later on. + prioritizeConstraints(newConstraints, strategy: .intrinsic(.zero)) - leadingConstraint = content.leadingAnchor.constraint(equalTo: leadingAnchor) - topConstraint = content.topAnchor.constraint(equalTo: topAnchor) - maxWidthConstraint = content.widthAnchor.constraint( - lessThanOrEqualToConstant: .maxConstraintValue) - fixedWidthConstraint = content.widthAnchor.constraint(equalToConstant: 0) - fixedHeightConstraint = content.heightAnchor.constraint(equalToConstant: 0) - - NSLayoutConstraint.activate([leadingConstraint, topConstraint].compactMap { $0 }) + NSLayoutConstraint.deactivate(Array(uiViewConstraints.values)) + uiViewConstraints = newConstraints + NSLayoutConstraint.activate(Array(uiViewConstraints.values)) } - private func updateSizeConstraints() { - // deactivate all size constraints to avoid side effects when doing a sizing pass to resolve the - // measurement strategy - let constraints = [ - maxWidthConstraint, - fixedWidthConstraint, - fixedHeightConstraint, - ].compactMap { $0 } - NSLayoutConstraint.deactivate(constraints) - - // avoid creating negative value constraints - let nonNegativeProposedSize = CGSize( - width: max(proposedSize.width, 0), - height: max(proposedSize.height, 0)) - - if let measuredSize = _measuredFittingSize { - fixedWidthConstraint?.constant = measuredSize.width - fixedHeightConstraint?.constant = measuredSize.height - fixedWidthConstraint?.isActive = true - fixedHeightConstraint?.isActive = true - } else { - switch resolvedStrategy { - case .proposed: - fixedWidthConstraint?.constant = nonNegativeProposedSize.width - fixedHeightConstraint?.constant = nonNegativeProposedSize.height - fixedWidthConstraint?.isActive = true - fixedHeightConstraint?.isActive = true - - case .intrinsicHeightProposedWidth: - fixedWidthConstraint?.constant = nonNegativeProposedSize.width - fixedWidthConstraint?.isActive = true - - case .intrinsicWidthProposedHeight: - fixedHeightConstraint?.constant = nonNegativeProposedSize.height - fixedHeightConstraint?.isActive = true - - case .intrinsicHeightProposedOrIntrinsicWidth: - maxWidthConstraint?.constant = nonNegativeProposedSize.width - maxWidthConstraint?.isActive = nonNegativeProposedSize.width > 0 - - case .intrinsic: - break // no op, all size constraints already deactivated - } + /// Prioritizes the given constraints based on the provided resolved strategy. + private func prioritizeConstraints( + _ constraints: [NSLayoutConstraint.Attribute: NSLayoutConstraint], + strategy: ResolvedSwiftUIMeasurementContainerStrategy) + { + // Give a required constraint in the dimensions that are fixed to the bounds, otherwise almost + // required. + switch strategy { + case .proposed: + constraints[.trailing]?.priority = .required + constraints[.bottom]?.priority = .required + case .intrinsicHeightProposedWidth: + constraints[.trailing]?.priority = .required + constraints[.bottom]?.priority = .almostRequired + case .intrinsicWidthProposedHeight: + constraints[.trailing]?.priority = .almostRequired + constraints[.bottom]?.priority = .required + case .intrinsic: + constraints[.trailing]?.priority = .almostRequired + constraints[.bottom]?.priority = .almostRequired } - } - - private func setNeedsUpdateConstraintsForPlatform() { - #if os(iOS) || os(tvOS) - setNeedsUpdateConstraints() - #elseif os(macOS) - needsUpdateConstraints = true - #endif - } - private func updateConstraintsForPlatformIfNeeded() { - #if os(iOS) || os(tvOS) - updateConstraintsIfNeeded() - #elseif os(macOS) - updateConstraintsForSubtreeIfNeeded() + #if os(macOS) + // On macOS, views default to having required constraints setting their height / width + // equal to their intrinsic content size. These have to be disabled in favor of the constraints + // we create here. + content.isVerticalContentSizeConstraintActive = false + content.isHorizontalContentSizeConstraintActive = false #endif } /// Measures the `uiView`, storing the resulting size in `measuredIntrinsicContentSize`. private func measureView() -> CGSize { - // immediately update constraints to the latest values so that the measurements below take them - // into account - updateConstraintsForPlatformIfNeeded() latestMeasurementBoundsSize = bounds.size + prioritizeConstraints(uiViewConstraints, strategy: resolvedStrategy) var measuredSize: CGSize let proposedSizeElseBounds = proposedSize.replacingNoIntrinsicMetric(with: bounds.size) @@ -315,14 +262,30 @@ public final class SwiftUIMeasurementContainer: ViewType { measuredSize = content.systemLayoutFittingIntrinsicWidthFixedHeight(proposedSizeElseBounds.height) measuredSize.height = ViewType.noIntrinsicMetric - case .intrinsicHeightProposedOrIntrinsicWidth: - let fittingSize = content.systemLayoutFittingIntrinsicSize() - measuredSize = CGSize( - width: min(fittingSize.width, proposedSize.width > 0 ? proposedSize.width : fittingSize.width), - height: fittingSize.height) - case .intrinsic(let size): measuredSize = size + + // If the measured size exceeds an available width or height, set the measured size to + // `noIntrinsicMetric` to ensure that the component can be compressed, otherwise it will + // overflow beyond the proposed size. + // - If the previous intrinsic content size is the same as the new proposed size, we don't + // do this as SwiftUI sometimes "proposes" the same intrinsic size back to the component and + // we don't want that scenario to prevent size changes when there is actually more space + // available. + if + proposedSize.width != ViewType.noIntrinsicMetric, + measuredSize.width > proposedSizeElseBounds.width, + _intrinsicContentSize.width != proposedSize.width + { + measuredSize.width = ViewType.noIntrinsicMetric + } + if + proposedSize.height != ViewType.noIntrinsicMetric, + measuredSize.height > proposedSizeElseBounds.height, + _intrinsicContentSize.height != proposedSize.height + { + measuredSize.height = ViewType.noIntrinsicMetric + } } _intrinsicContentSize = measuredSize @@ -343,8 +306,9 @@ public enum SwiftUIMeasurementContainerStrategy { /// - The `uiView` will be given its intrinsic width and/or height when measurement in that /// dimension produces a positive value, while zero/negative values will result in that /// dimension receiving the available space proposed by the parent. - /// - If the view contains `UILabel` subviews that require a double layout pass as determined by supporting multiple lines of text - /// the view will default to `intrinsicHeightProposedOrIntrinsicWidth` to allow the labels to wrap. + /// - If the view contains `UILabel` subviews that require a double layout pass as determined by + /// a `preferredMaxLayoutWidth` that's greater than zero after a layout, then the view will + /// default to `intrinsicHeightProposedWidth` to allow the labels to wrap. /// /// If you would like to opt out of automatic sizing for performance or to override the default /// behavior, choose another strategy. @@ -355,17 +319,11 @@ public enum SwiftUIMeasurementContainerStrategy { /// Typically used for views that should expand greedily in both axes, e.g. a background view. case proposed - /// The `uiView`'s receives either its intrinsic width or the proposed width, whichever is smaller. The view receives its intrinsic height - /// based on the chosen width. - /// - /// Typically used for views that have a height that's a function of their width, e.g. a row with - /// text that can wrap to multiple lines. - case intrinsicHeightProposedOrIntrinsicWidth - /// The `uiView` is sized with its intrinsic height and expands horizontally to fill the width /// proposed by its parent. /// - /// Typically used for views that have a height that's a function of their parent's width. + /// Typically used for views that have a height that's a function of their width, e.g. a row with + /// text that can wrap to multiple lines. case intrinsicHeightProposedWidth /// The `uiView` is sized with its intrinsic width and expands vertically to fill the height @@ -387,8 +345,7 @@ public enum SwiftUIMeasurementContainerStrategy { /// The resolved measurement strategy of a `SwiftUIMeasurementContainer`, matching the cases of the /// `SwiftUIMeasurementContainerStrategy` without the automatic case. private enum ResolvedSwiftUIMeasurementContainerStrategy { - case proposed, intrinsicHeightProposedWidth, intrinsicWidthProposedHeight, - intrinsicHeightProposedOrIntrinsicWidth, intrinsic(CGSize) + case proposed, intrinsicHeightProposedWidth, intrinsicWidthProposedHeight, intrinsic(CGSize) } // MARK: - UILayoutPriority @@ -456,16 +413,16 @@ extension ViewType { #endif } - /// Whether this view or any of its subviews has a subview that has a double layout pass `UILabel` as determined by being - /// configured to show multiple lines of text. This view should get a `intrinsicHeightProposedOrIntrinsicWidth` sizing - /// strategy so that it wraps correctly. + /// Whether this view or any of its subviews has a subview that has a double layout pass `UILabel` + /// as determined by a non-zero `preferredMaxLayoutWidth`, which implies that it should get a + /// `intrinsicHeightProposedWidth` sizing strategy to allow the label to wrap and grow. @nonobjc fileprivate func containsDoubleLayoutPassSubviews() -> Bool { #if os(macOS) return false #else var contains = false - if let label = self as? UILabel, label.numberOfLines != 1 { + if let label = self as? UILabel, label.preferredMaxLayoutWidth > 0 { contains = true } for subview in subviews {