Skip to content

Commit

Permalink
Refine UIKit to SwiftUI Measurement Strategies (#162)
Browse files Browse the repository at this point in the history
* Update UIKit from swiftUI measurement strategies

* Add example code

* Swiftlint and fixes

* CI fixes

* Updates for self review

* Updates for PR feedback
  • Loading branch information
brynbodayle authored Mar 1, 2024
1 parent ecee1ac commit fb869c4
Show file tree
Hide file tree
Showing 7 changed files with 323 additions and 108 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 8 additions & 4 deletions Example/EpoxyExample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 52;
objectVersion = 54;
objects = {

/* Begin PBXBuildFile section */
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -106,6 +107,7 @@
25F71A9D273D990E004D30CE /* DynamicRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicRow.swift; sourceTree = "<group>"; };
25FEB79125AE431100F8EFBD /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
2E8B007523F47E7E00D82A31 /* CustomSizingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSizingView.swift; sourceTree = "<group>"; };
601A2B0E2B716C5800FDB8FE /* EpoxyInSwiftUISizingStrategiesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpoxyInSwiftUISizingStrategiesViewController.swift; sourceTree = "<group>"; };
A5AD02A62637CBF9007261BC /* TextFieldRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldRow.swift; sourceTree = "<group>"; };
A61AFF582602B86E005356A8 /* Example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example.swift; sourceTree = "<group>"; };
A61AFF5B2602B8D7005356A8 /* ReadmeExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadmeExample.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 = (
Expand All @@ -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 = (
Expand Down
5 changes: 5 additions & 0 deletions Example/EpoxyExample/Data/Example.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum Example: CaseIterable {
case layoutGroups
case swiftUIToEpoxy
case epoxyToSwiftUI
case epoxyToSwiftUISizingStrategies
case swiftUIToEpoxyResizing

// MARK: Internal
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
}
Expand Down
2 changes: 2 additions & 0 deletions Example/EpoxyExample/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ final class MainViewController: NavigationController {
return SwiftUIInEpoxyViewController()
case .epoxyToSwiftUI:
return EpoxyInSwiftUIViewController()
case .epoxyToSwiftUISizingStrategies:
return EpoxyInSwiftUISizingStrategiesViewController()
case .swiftUIToEpoxyResizing:
return SwiftUIInEpoxyResizingViewController()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EpoxyInSwiftUISizingStrategiesView> {
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<UILabel, Void>.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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
}

}
Loading

0 comments on commit fb869c4

Please sign in to comment.