Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

8) Infinite size elements #450

Merged
merged 3 commits into from
Apr 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions BlueprintUI/Sources/Extensions/CGSize+Blueprint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import CoreGraphics

extension CGSize {
/// A size with `infinity` in both dimensions.
public static let infinity = CGSize(width: CGFloat.infinity, height: .infinity)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One has to love it.


/// Returns a size with infinite dimensions replaced by the values from the given replacement.
public func replacingInfinity(with replacement: CGSize) -> CGSize {
assert(replacement.isFinite, "Infinity replacement value must be finite")

return CGSize(
width: width.replacingInfinity(with: replacement.width),
height: height.replacingInfinity(with: replacement.height)
)
}
}
10 changes: 10 additions & 0 deletions BlueprintUI/Sources/Extensions/FloatingPoint+Blueprint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

extension FloatingPoint {
/// Returns `replacement` if `self.isInfinite` is `true`, or `self` if `self` is finite.
public func replacingInfinity(with replacement: Self) -> Self {
assert(replacement.isFinite, "Infinity replacement value must be finite")

return isInfinite ? replacement : self
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import CoreGraphics
import UIKit

extension CGSize {
static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
Expand Down
2 changes: 1 addition & 1 deletion BlueprintUI/Sources/Internal/LayoutModeKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ enum LayoutModeKey: EnvironmentKey {
extension Environment {
/// This mode will be inherited by descendant BlueprintViews that do not have an explicit
/// mode set.
var layoutMode: LayoutMode {
public internal(set) var layoutMode: LayoutMode {
get { self[LayoutModeKey.self] }
set { self[LayoutModeKey.self] = newValue }
}
Expand Down
20 changes: 20 additions & 0 deletions BlueprintUI/Sources/Layout/LayoutMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ public enum LayoutMode: Equatable {
public static let caffeinated = Self.caffeinated()
}

extension LayoutMode: CustomStringConvertible {
public var description: String {
switch self {
case .legacy:
return "Legacy"
case .caffeinated(let options):
switch (options.hintRangeBoundaries, options.searchUnconstrainedKeys) {
case (true, true):
return "Caffeinated (hint+search)"
case (true, false):
return "Caffeinated (hint)"
case (false, true):
return "Caffeinated (search)"
case (false, false):
return "Caffeinated"
}
}
}
}

extension Notification.Name {
static let defaultLayoutModeChanged: Self = .init(
"com.squareup.blueprint.defaultLayoutModeChanged"
Expand Down
18 changes: 14 additions & 4 deletions BlueprintUICommonControls/Sources/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ public struct Image: Element {

public var content: ElementContent {
let measurer = Measurer(contentMode: contentMode, imageSize: image?.size)
return ElementContent(measurable: measurer)
return ElementContent { constraint, environment in
measurer.measure(in: constraint, layoutMode: environment.layoutMode)
}
}

public func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? {
Expand Down Expand Up @@ -106,18 +108,19 @@ extension CGSize {

extension Image {

fileprivate struct Measurer: Measurable {
fileprivate struct Measurer {

var contentMode: ContentMode
var imageSize: CGSize?

func measure(in constraint: SizeConstraint) -> CGSize {
func measure(in constraint: SizeConstraint, layoutMode: LayoutMode) -> CGSize {
guard let imageSize = imageSize else { return .zero }

enum Mode {
case fitWidth(CGFloat)
case fitHeight(CGFloat)
case useImageSize
case infinite
}

let mode: Mode
Expand All @@ -137,7 +140,12 @@ extension Image {
} else if case .atMost(let height) = constraint.height {
mode = .fitHeight(height)
} else {
mode = .useImageSize
switch layoutMode {
case .legacy:
mode = .useImageSize
case .caffeinated:
mode = .infinite
Copy link
Contributor

@nononoah nononoah Apr 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you unpack this a bit for me?

Given an unconstrained measurement space, Image now requests infinite layout space. Does that not change the layout behavior of images in these contexts, and therefore any container of these images?

Or is it that .infinite opts them out of caffeinated layout in a way that leads to the restoration of existing behavior?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given an unconstrained measurement space, Image now requests infinite layout space. Does that not change the layout behavior of images in these contexts, and therefore any container of these images?

It does! Practically speaking, there's not that much impact, because the need to measure an element fully unconstrained is somewhat rare.

There's perhaps a semantic change — if you were counting on unconstrained measurement to get you something like an intrinsic size, or SwiftUI's concept of "ideal size", that's no longer possible. But it was never guaranteed to work that way, and would be inconsistent depending on what you measured.

Or is it that .infinite opts them out of caffeinated layout in a way that leads to the restoration of existing behavior?

Nope, there's no way to opt out at the element level.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because of the cache hinting optimizations? I guess I would expect for the measurement modes that don't stretch the image, I'd expect to return the size of image, even in an infinite constraint.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because of the cache hinting optimizations?

It's required by the new layout contract, yeah.

I guess I would expect for the measurement modes that don't stretch the image, I'd expect to return the size of image, even in an infinite constraint.

Well, the aspect fit & fill modes already fill space today, even under finite constraints. Given one finite constraint, they'll fill it, no matter how large. So the reasoning is that if the size grows continuously with the constraint, why wouldn't it grow to infinity when the constraint grows to infinity?

We could change the behavior of these modes to return the size of the image without filling space, and that would be compliant too, but it would be a much bigger breaking change to existing layouts.

}
}
}

Expand All @@ -154,6 +162,8 @@ extension Image {
)
case .useImageSize:
return imageSize
case .infinite:
return .infinity
}


Expand Down
10 changes: 8 additions & 2 deletions BlueprintUICommonControls/Sources/ScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,12 @@ extension ScrollView {
result.width += contentInset.left + contentInset.right
result.height += contentInset.top + contentInset.bottom

result.width = min(result.width, proposal.width.maximum)
result.height = min(result.height, proposal.height.maximum)
if let maxWidth = proposal.width.constrainedValue {
result.width = min(result.width, maxWidth)
}
if let maxHeight = proposal.height.constrainedValue {
result.height = min(result.height, maxHeight)
}

return result
}
Expand All @@ -210,6 +214,8 @@ extension ScrollView {
insetSize.height -= contentInset.top + contentInset.bottom

var itemSize = fittedSize(in: .init(insetSize), subelement: subelement)
.replacingInfinity(with: insetSize)

if contentSize == .fittingHeight {
itemSize.width = insetSize.width
} else if contentSize == .fittingWidth {
Expand Down
18 changes: 13 additions & 5 deletions BlueprintUICommonControls/Sources/TextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,19 @@ public struct TextField: Element {
}

public var content: ElementContent {
ElementContent { constraint -> CGSize in
CGSize(
width: max(constraint.maximum.width, 44),
height: 44.0
)
ElementContent { constraint, environment -> CGSize in
switch environment.layoutMode {
case .legacy:
return CGSize(
width: max(constraint.maximum.width, 44),
height: 44.0
)
case .caffeinated:
return CGSize(
width: constraint.width.constrainedValue.map { max($0, 44) } ?? .infinity,
height: 44
)
}
}
}

Expand Down
78 changes: 63 additions & 15 deletions BlueprintUICommonControls/Tests/Sources/ImageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ class ImageTests: XCTestCase {
size: CGSize,
constraint: SizeConstraint,
expectedValue: CGSize,
layoutModes: [LayoutMode] = LayoutMode.testModes,
line: UInt = #line
) {
self.validate(
contentMode: .aspectFill,
imageSize: size,
constraint: constraint,
layoutModes: layoutModes,
expectedValue: expectedValue,
line: line
)
Expand All @@ -58,7 +60,15 @@ class ImageTests: XCTestCase {
validate(
size: .init(width: 20, height: 10),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .init(width: 20, height: 10)
expectedValue: .init(width: 20, height: 10),
layoutModes: [.legacy]
)

validate(
size: .init(width: 20, height: 10),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .infinity,
layoutModes: [.caffeinated]
)

validate(
Expand Down Expand Up @@ -108,7 +118,15 @@ class ImageTests: XCTestCase {
validate(
size: .init(width: 10, height: 20),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .init(width: 10, height: 20)
expectedValue: .init(width: 10, height: 20),
layoutModes: [.legacy]
)

validate(
size: .init(width: 10, height: 20),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .infinity,
layoutModes: [.caffeinated]
)

validate(
Expand Down Expand Up @@ -159,12 +177,14 @@ class ImageTests: XCTestCase {
size: CGSize,
constraint: SizeConstraint,
expectedValue: CGSize,
layoutModes: [LayoutMode] = LayoutMode.testModes,
line: UInt = #line
) {
self.validate(
contentMode: .aspectFit,
imageSize: size,
constraint: constraint,
layoutModes: layoutModes,
expectedValue: expectedValue,
line: line
)
Expand All @@ -175,7 +195,15 @@ class ImageTests: XCTestCase {
validate(
size: .init(width: 20, height: 10),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .init(width: 20, height: 10)
expectedValue: .init(width: 20, height: 10),
layoutModes: [.legacy]
)

validate(
size: .init(width: 20, height: 10),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .infinity,
layoutModes: [.caffeinated]
)

validate(
Expand Down Expand Up @@ -225,7 +253,15 @@ class ImageTests: XCTestCase {
validate(
size: .init(width: 10, height: 20),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .init(width: 10, height: 20)
expectedValue: .init(width: 10, height: 20),
layoutModes: [.legacy]
)

validate(
size: .init(width: 10, height: 20),
constraint: .init(width: .unconstrained, height: .unconstrained),
expectedValue: .infinity,
layoutModes: [.caffeinated]
)

validate(
Expand Down Expand Up @@ -276,12 +312,14 @@ class ImageTests: XCTestCase {
size: CGSize,
constraint: SizeConstraint,
expectedValue: CGSize,
layoutModes: [LayoutMode] = LayoutMode.testModes,
line: UInt = #line
) {
self.validate(
contentMode: .center,
imageSize: size,
constraint: constraint,
layoutModes: layoutModes,
expectedValue: expectedValue,
line: line
)
Expand Down Expand Up @@ -393,12 +431,14 @@ class ImageTests: XCTestCase {
size: CGSize,
constraint: SizeConstraint,
expectedValue: CGSize,
layoutModes: [LayoutMode] = LayoutMode.testModes,
line: UInt = #line
) {
self.validate(
contentMode: .stretch,
imageSize: size,
constraint: constraint,
layoutModes: layoutModes,
expectedValue: expectedValue,
line: line
)
Expand Down Expand Up @@ -509,6 +549,7 @@ class ImageTests: XCTestCase {
contentMode: Image.ContentMode,
imageSize: CGSize,
constraint: SizeConstraint,
layoutModes: [LayoutMode],
expectedValue: CGSize,
line: UInt = #line
) {
Expand All @@ -518,17 +559,24 @@ class ImageTests: XCTestCase {
contentMode: contentMode
)

XCTAssertEqual(
element.content.measure(in: constraint),
expectedValue,
"""
Image in content mode: \(contentMode),
of size (\(Int(imageSize.width))x\(Int(imageSize.height)))
expected to be measured as (\(Int(expectedValue.width))x\(Int(expectedValue.height)))
in constraint: (\(constraint))
""",
line: line
)
for layoutMode in layoutModes {
let actualSize = layoutMode.performAsDefault {
element.content.measure(in: constraint)
}

XCTAssertEqual(
actualSize,
expectedValue,
"""
Image in content mode: \(contentMode),
of size \(imageSize)
expected to be measured as \(expectedValue)
in constraint: (\(constraint))
and layout mode: \(layoutMode)
""",
line: line
)
}
}
}

Expand Down
16 changes: 16 additions & 0 deletions BlueprintUICommonControls/Tests/Sources/LayoutMode+Testing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import BlueprintUI

extension LayoutMode {
static let testModes: [LayoutMode] = [.legacy, .caffeinated]

/// Run the given block with `self` as the default layout mode, restoring the previous default
/// afterwards, and returning the result of the block.
func performAsDefault<Result>(block: () throws -> Result) rethrows -> Result {
let oldLayoutMode = LayoutMode.default
defer { LayoutMode.default = oldLayoutMode }

LayoutMode.default = self

return try block()
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading