diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4767e3..1ddcd0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ 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.xcodeproj/project.pbxproj b/Example/EpoxyExample.xcodeproj/project.pbxproj index e2819189..29a8d2d7 100644 --- a/Example/EpoxyExample.xcodeproj/project.pbxproj +++ b/Example/EpoxyExample.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -41,6 +41,7 @@ 25F71A9E273D990E004D30CE /* DynamicRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F71A9D273D990E004D30CE /* DynamicRow.swift */; }; 25FEB79225AE431100F8EFBD /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25FEB79125AE431100F8EFBD /* MainViewController.swift */; }; 2E8B007623F47E7E00D82A31 /* CustomSizingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8B007523F47E7E00D82A31 /* CustomSizingView.swift */; }; + 601A2B0F2B716C5800FDB8FE /* EpoxyInSwiftUISizingStrategiesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 601A2B0E2B716C5800FDB8FE /* EpoxyInSwiftUISizingStrategiesViewController.swift */; }; A5AD02A72637CBF9007261BC /* TextFieldRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AD02A62637CBF9007261BC /* TextFieldRow.swift */; }; A61AFF592602B86E005356A8 /* Example.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61AFF582602B86E005356A8 /* Example.swift */; }; A61AFF5C2602B8D7005356A8 /* ReadmeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61AFF5B2602B8D7005356A8 /* ReadmeExample.swift */; }; @@ -106,6 +107,7 @@ 25F71A9D273D990E004D30CE /* DynamicRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicRow.swift; sourceTree = ""; }; 25FEB79125AE431100F8EFBD /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; 2E8B007523F47E7E00D82A31 /* CustomSizingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSizingView.swift; sourceTree = ""; }; + 601A2B0E2B716C5800FDB8FE /* EpoxyInSwiftUISizingStrategiesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpoxyInSwiftUISizingStrategiesViewController.swift; sourceTree = ""; }; A5AD02A62637CBF9007261BC /* TextFieldRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldRow.swift; sourceTree = ""; }; A61AFF582602B86E005356A8 /* Example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example.swift; sourceTree = ""; }; A61AFF5B2602B8D7005356A8 /* ReadmeExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadmeExample.swift; sourceTree = ""; }; @@ -298,9 +300,10 @@ A6725564271787E50085346B /* SwiftUI */ = { isa = PBXGroup; children = ( + 601A2B0E2B716C5800FDB8FE /* EpoxyInSwiftUISizingStrategiesViewController.swift */, A67255602717874A0085346B /* EpoxyInSwiftUIViewController.swift */, - A67255612717874A0085346B /* SwiftUIInEpoxyViewController.swift */, A6BABA742874B6E6004C49E3 /* SwiftUIInEpoxyResizingViewController.swift */, + A67255612717874A0085346B /* SwiftUIInEpoxyViewController.swift */, ); path = SwiftUI; sourceTree = ""; @@ -443,6 +446,7 @@ 25D39B5C262789E000B3DBF9 /* AlignableTextRow.swift in Sources */, A6BABA752874B6E6004C49E3 /* SwiftUIInEpoxyResizingViewController.swift in Sources */, 25D39B3626277F0D00B3DBF9 /* ColorsViewController.swift in Sources */, + 601A2B0F2B716C5800FDB8FE /* EpoxyInSwiftUISizingStrategiesViewController.swift in Sources */, 25B6765F25AE883700C00B20 /* ProductViewController.swift in Sources */, A61AFF632602BA2B005356A8 /* NavigationWrapperViewController.swift in Sources */, 25D39B3726277F0D00B3DBF9 /* LayoutGroupsReadmeExamplesViewController.swift in Sources */, @@ -610,7 +614,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5LL7P8E8RA; INFOPLIST_FILE = EpoxyExample/Assets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -629,7 +633,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5LL7P8E8RA; INFOPLIST_FILE = EpoxyExample/Assets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Example/EpoxyExample/Data/Example.swift b/Example/EpoxyExample/Data/Example.swift index 2aa38455..b44a6e4c 100644 --- a/Example/EpoxyExample/Data/Example.swift +++ b/Example/EpoxyExample/Data/Example.swift @@ -14,6 +14,7 @@ enum Example: CaseIterable { case layoutGroups case swiftUIToEpoxy case epoxyToSwiftUI + case epoxyToSwiftUISizingStrategies case swiftUIToEpoxyResizing // MARK: Internal @@ -42,6 +43,8 @@ enum Example: CaseIterable { return "SwiftUI in Epoxy" case .epoxyToSwiftUI: return "Epoxy in SwiftUI" + case .epoxyToSwiftUISizingStrategies: + return "Epoxy in SwiftUI, Sizing Strategies" case .swiftUIToEpoxyResizing: return "SwiftUI in Epoxy, Resizing Cells" } @@ -71,6 +74,8 @@ enum Example: CaseIterable { return "An example of SwiftUI views being embedded in Epoxy" case .epoxyToSwiftUI: return "An example of Epoxy views being embedded in SwiftUI" + case .epoxyToSwiftUISizingStrategies: + return "An example of the different strategies to size Epoxy views being embedded in SwiftUI" case .swiftUIToEpoxyResizing: return "An example of SwiftUI views being embedded in Epoxy that can invalidate their size" } diff --git a/Example/EpoxyExample/ViewControllers/MainViewController.swift b/Example/EpoxyExample/ViewControllers/MainViewController.swift index 18b2ee9f..28bf7010 100644 --- a/Example/EpoxyExample/ViewControllers/MainViewController.swift +++ b/Example/EpoxyExample/ViewControllers/MainViewController.swift @@ -120,6 +120,8 @@ final class MainViewController: NavigationController { return SwiftUIInEpoxyViewController() case .epoxyToSwiftUI: return EpoxyInSwiftUIViewController() + case .epoxyToSwiftUISizingStrategies: + return EpoxyInSwiftUISizingStrategiesViewController() case .swiftUIToEpoxyResizing: return SwiftUIInEpoxyResizingViewController() } diff --git a/Example/EpoxyExample/ViewControllers/SwiftUI/EpoxyInSwiftUISizingStrategiesViewController.swift b/Example/EpoxyExample/ViewControllers/SwiftUI/EpoxyInSwiftUISizingStrategiesViewController.swift new file mode 100644 index 00000000..26e2b3fa --- /dev/null +++ b/Example/EpoxyExample/ViewControllers/SwiftUI/EpoxyInSwiftUISizingStrategiesViewController.swift @@ -0,0 +1,137 @@ +// Created by Bryn Bodayle on 2/5/24. +// Copyright © 2024 Airbnb Inc. All rights reserved. + +import Epoxy +import SwiftUI +import UIKit + +// MARK: - EpoxyInSwiftUISizingStrategiesViewController + +/// Demo of the various sizing strategies for UIKit views bridged to SwiftUI +final class EpoxyInSwiftUISizingStrategiesViewController: UIHostingController { + init() { + super.init(rootView: EpoxyInSwiftUISizingStrategiesView()) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - EpoxyInSwiftUISizingStrategiesView + +struct EpoxyInSwiftUISizingStrategiesView: View { + + // MARK: Internal + + let text = "The text" + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + Text("Word Count: \(wordCount)") + .padding() + Slider(value: $wordCount, in: 0...100) + .padding() + Text("Proposed Width/Height set to 150pt") + .padding() + + ForEach(SwiftUIMeasurementContainerStrategy.allCases) { value in + Text(value.displayString) + .bold() + .padding() + LabelView( + text: BeloIpsum.sentence(count: 1, wordCount: Int(wordCount)), + measurementStrategy: value) + .frame(width: value.proposedWidth, height: value.proposedHeight) + .border(.red) + } + } + } + } + + // MARK: Private + + @State private var wordCount = 12.0 +} + +// MARK: - SwiftUIMeasurementContainerStrategy + Identifiable, CaseIterable + +extension SwiftUIMeasurementContainerStrategy: Identifiable, CaseIterable { + + // MARK: Public + + public static var allCases: [SwiftUIMeasurementContainerStrategy] = [ + .automatic, + .proposed, + .intrinsicHeightProposedOrIntrinsicWidth, + .intrinsicHeightProposedWidth, + .intrinsicWidthProposedHeight, + .intrinsic, + ] + + public var id: Self { + self + } + + // MARK: Internal + + var displayString: String { + switch self { + case .automatic: + 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: + return "Intrinsic Width, Proposed Height" + case .intrinsic: + return "Intrinsic" + } + } + + var proposedWidth: CGFloat? { + switch self { + case .proposed, .intrinsicHeightProposedWidth: + return 150 + default: + return nil + } + } + + var proposedHeight: CGFloat? { + switch self { + case .proposed, .intrinsicWidthProposedHeight: + return 150 + default: + return nil + } + } +} + +// MARK: - LabelView + +struct LabelView: UIViewConfiguringSwiftUIView { + + let text: String? + let measurementStrategy: SwiftUIMeasurementContainerStrategy + + var configurations = [SwiftUIView.Configuration]() + + var body: some View { + UILabel.swiftUIView { + let label = UILabel(frame: .zero) + label.numberOfLines = 0 + return label + } + .configure { context in + context.view.text = text + context.container.invalidateIntrinsicContentSize() + } + .configurations(configurations) + .sizing(measurementStrategy) + } +} diff --git a/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift b/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift index ed4fdc7c..bda29027 100644 --- a/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift +++ b/Sources/EpoxyCore/SwiftUI/LayoutUtilities/MeasuringViewRepresentable.swift @@ -56,13 +56,16 @@ 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, - height: children.first { $0.label == "height" }?.value as? CGFloat ?? ViewType.noIntrinsicMetric) - + width: ( + children.first { $0.label == "width" }? + .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue, + height: ( + children.first { $0.label == "height" }? + .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue) size = uiView.measuredFittingSize } - #if swift(>=5.7) // Proxy check for being built with the iOS 15 SDK + #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, *) public func sizeThatFits( _ proposal: ProposedViewSize, @@ -71,12 +74,7 @@ extension MeasuringViewRepresentable { -> CGSize? { uiView.strategy = sizing - - // Creates a size by replacing `nil`s with `UIView.noIntrinsicMetric` - uiView.proposedSize = .init( - width: proposal.width ?? ViewType.noIntrinsicMetric, - height: proposal.height ?? ViewType.noIntrinsicMetric) - + uiView.proposedSize = proposal.viewTypeValue return uiView.measuredFittingSize } #endif @@ -91,14 +89,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, - height: children.first { $0.label == "height" }?.value as? CGFloat ?? ViewType.noIntrinsicMetric) - + width: ( + children.first { $0.label == "width" }? + .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue, + height: ( + children.first { $0.label == "height" }? + .value as? CGFloat ?? ViewType.noIntrinsicMetric).constraintSafeValue) size = nsView.measuredFittingSize } @@ -112,14 +110,38 @@ extension MeasuringViewRepresentable { -> CGSize? { nsView.strategy = sizing - - // Creates a size by replacing `nil`s with `UIView.noIntrinsicMetric` - nsView.proposedSize = .init( - width: proposal.width ?? ViewType.noIntrinsicMetric, - height: proposal.height ?? ViewType.noIntrinsicMetric) - + nsView.proposedSize = proposal.viewTypeValue 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 8730410f..eb6b329d 100644 --- a/Sources/EpoxyCore/SwiftUI/LayoutUtilities/SwiftUIMeasurementContainer.swift +++ b/Sources/EpoxyCore/SwiftUI/LayoutUtilities/SwiftUIMeasurementContainer.swift @@ -1,6 +1,11 @@ // 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 @@ -73,8 +78,10 @@ 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() } } @@ -128,6 +135,11 @@ 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 @@ -140,14 +152,21 @@ public final class SwiftUIMeasurementContainer: ViewType { /// The bounds size at the time of the latest measurement. private var latestMeasurementBoundsSize: CGSize? - /// The most recently updated set of constraints constraining `uiView` to `self`. - private var uiViewConstraints = [NSLayoutConstraint.Attribute: NSLayoutConstraint]() + private var topConstraint: NSLayoutConstraint? + private var leadingConstraint: NSLayoutConstraint? + private var maxWidthConstraint: NSLayoutConstraint? + private var fixedWidthConstraint: NSLayoutConstraint? + private var fixedHeightConstraint: 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? + private var _measuredFittingSize: CGSize? { + didSet { + setNeedsUpdateConstraintsForPlatform() + } + } /// The resolved measurement strategy. private var resolvedStrategy: ResolvedSwiftUIMeasurementContainerStrategy { @@ -158,15 +177,10 @@ public final class SwiftUIMeasurementContainer: ViewType { let resolved: ResolvedSwiftUIMeasurementContainerStrategy switch strategy { case .automatic: - // 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 + if content.containsDoubleLayoutPassSubviews() { + resolved = .intrinsicHeightProposedOrIntrinsicWidth } else { + let intrinsicSize = content.systemLayoutFittingIntrinsicSize() let zero = CGFloat(0) switch (width: intrinsicSize.width, height: intrinsicSize.height) { case (width: ...zero, height: ...zero): @@ -185,6 +199,8 @@ public final class SwiftUIMeasurementContainer: ViewType { resolved = .intrinsicHeightProposedWidth case .intrinsicWidthProposedHeight: resolved = .intrinsicWidthProposedHeight + case .intrinsicHeightProposedOrIntrinsicWidth: + resolved = .intrinsicHeightProposedOrIntrinsicWidth case .intrinsic: resolved = .intrinsic(content.systemLayoutFittingIntrinsicSize()) } @@ -195,57 +211,94 @@ public final class SwiftUIMeasurementContainer: ViewType { private func setUpConstraints() { content.translatesAutoresizingMaskIntoConstraints = false - 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, + let oldConstraints = [ + leadingConstraint, + topConstraint, + maxWidthConstraint, + fixedWidthConstraint, + fixedHeightConstraint, ] - // 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)) + .compactMap { $0 } + NSLayoutConstraint.deactivate(oldConstraints) - NSLayoutConstraint.deactivate(Array(uiViewConstraints.values)) - uiViewConstraints = newConstraints - NSLayoutConstraint.activate(Array(uiViewConstraints.values)) + 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 }) } - /// 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 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 + } } + } - #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 + 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() #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) @@ -262,30 +315,14 @@ 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 @@ -306,9 +343,8 @@ 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 - /// a `preferredMaxLayoutWidth` that's greater than zero after a layout, then the view will - /// default to `intrinsicHeightProposedWidth` to allow the labels to wrap. + /// - 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 you would like to opt out of automatic sizing for performance or to override the default /// behavior, choose another strategy. @@ -319,11 +355,17 @@ public enum SwiftUIMeasurementContainerStrategy { /// Typically used for views that should expand greedily in both axes, e.g. a background view. case proposed - /// The `uiView` is sized with its intrinsic height and expands horizontally to fill the width - /// proposed by its parent. + /// 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. case intrinsicHeightProposedWidth /// The `uiView` is sized with its intrinsic width and expands vertically to fill the height @@ -345,7 +387,8 @@ 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, intrinsic(CGSize) + case proposed, intrinsicHeightProposedWidth, intrinsicWidthProposedHeight, + intrinsicHeightProposedOrIntrinsicWidth, intrinsic(CGSize) } // MARK: - UILayoutPriority @@ -413,16 +456,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 a non-zero `preferredMaxLayoutWidth`, which implies that it should get a - /// `intrinsicHeightProposedWidth` sizing strategy to allow the label to wrap and grow. + /// 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. @nonobjc fileprivate func containsDoubleLayoutPassSubviews() -> Bool { #if os(macOS) return false #else var contains = false - if let label = self as? UILabel, label.preferredMaxLayoutWidth > 0 { + if let label = self as? UILabel, label.numberOfLines != 1 { contains = true } for subview in subviews {