From 02670114b281403447a32c8fc24e44dd23cbbba3 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Tue, 29 Oct 2024 23:10:24 -0700 Subject: [PATCH 1/4] Create non-@MainActor-bound methods for rounding to pixel at scale --- Paralayout/AspectRatio.swift | 117 +++++++++++++---- Paralayout/PixelRounding.swift | 152 ++++++++++++++++------ ParalayoutTests/AspectRatioTests.swift | 4 +- ParalayoutTests/ScaleFactorProvider.swift | 28 ++++ 4 files changed, 234 insertions(+), 67 deletions(-) create mode 100644 ParalayoutTests/ScaleFactorProvider.swift diff --git a/Paralayout/AspectRatio.swift b/Paralayout/AspectRatio.swift index 2080901..c9c22d0 100644 --- a/Paralayout/AspectRatio.swift +++ b/Paralayout/AspectRatio.swift @@ -39,7 +39,7 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// An inverted representation of the AspectRatio. public var inverted: AspectRatio { - return AspectRatio(width: ratioHeight, height: ratioWidth) + AspectRatio(width: ratioHeight, height: ratioWidth) } // MARK: - Life Cycle @@ -74,29 +74,29 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { // MARK: - Comparable public static func == (lhs: AspectRatio, rhs: AspectRatio) -> Bool { - return (lhs.ratioWidth * rhs.ratioHeight == lhs.ratioHeight * rhs.ratioWidth) + (lhs.ratioWidth * rhs.ratioHeight == lhs.ratioHeight * rhs.ratioWidth) } public static func < (lhs: AspectRatio, rhs: AspectRatio) -> Bool { - return (lhs.ratioWidth * rhs.ratioHeight < lhs.ratioHeight * rhs.ratioWidth) + (lhs.ratioWidth * rhs.ratioHeight < lhs.ratioHeight * rhs.ratioWidth) } public static func <= (lhs: AspectRatio, rhs: AspectRatio) -> Bool { - return (lhs.ratioWidth * rhs.ratioHeight <= lhs.ratioHeight * rhs.ratioWidth) + (lhs.ratioWidth * rhs.ratioHeight <= lhs.ratioHeight * rhs.ratioWidth) } public static func >= (lhs: AspectRatio, rhs: AspectRatio) -> Bool { - return (lhs.ratioWidth * rhs.ratioHeight >= lhs.ratioHeight * rhs.ratioWidth) + (lhs.ratioWidth * rhs.ratioHeight >= lhs.ratioHeight * rhs.ratioWidth) } public static func > (lhs: AspectRatio, rhs: AspectRatio) -> Bool { - return (lhs.ratioWidth * rhs.ratioHeight > lhs.ratioHeight * rhs.ratioWidth) + (lhs.ratioWidth * rhs.ratioHeight > lhs.ratioHeight * rhs.ratioWidth) } // MARK: - DebugStringConvertible public var debugDescription: String { - return ("AspectRatio<\(ratioWidth):\(ratioHeight)>") + ("AspectRatio<\(ratioWidth):\(ratioHeight)>") } // MARK: - Public Methods @@ -107,7 +107,15 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// - parameter scaleFactor: The view/window/screen to use for pixel rounding. @MainActor public func height(forWidth width: CGFloat, in scaleFactor: ScaleFactorProviding) -> CGFloat { - return (ratioHeight * width / ratioWidth).roundedToPixel(in: scaleFactor) + self.height(forWidth: width, in: scaleFactor.pixelsPerPoint) + } + + /// Returns the height of the aspect ratio for a given `width` rounded to the nearest pixel. + /// + /// - parameter width: The desired width. + /// - parameter scale: The number of pixels per point. + public func height(forWidth width: CGFloat, in scale: CGFloat) -> CGFloat { + (ratioHeight * width / ratioWidth).roundedToPixel(in: scale) } /// Returns the width of the aspect ratio for a given `height` rounded to the nearest pixel. @@ -116,7 +124,15 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// - parameter scaleFactor: The view/window/screen to use for pixel rounding. @MainActor public func width(forHeight height: CGFloat, in scaleFactor: ScaleFactorProviding) -> CGFloat { - return (ratioWidth * height / ratioHeight).roundedToPixel(in: scaleFactor) + self.width(forHeight: height, in: scaleFactor.pixelsPerPoint) + } + + /// Returns the width of the aspect ratio for a given `height` rounded to the nearest pixel. + /// + /// - parameter height: The desired height. + /// - parameter scale: The number of pixels per point. + public func width(forHeight height: CGFloat, in scale: CGFloat) -> CGFloat { + (ratioWidth * height / ratioHeight).roundedToPixel(in: scale) } /// Returns a size of the aspect ratio with the specified `width`. The size's `height` will be rounded to the @@ -126,9 +142,18 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// - parameter scaleFactor: The view/window/screen to use for pixel rounding. @MainActor public func size(forWidth width: CGFloat, in scaleFactor: ScaleFactorProviding) -> CGSize { - return CGSize( + self.size(forWidth: width, in: scaleFactor.pixelsPerPoint) + } + + /// Returns a size of the aspect ratio with the specified `width`. The size's `height` will be rounded to the + /// nearest pixel. + /// + /// - parameter width: The desired width. + /// - parameter scale: The number of pixels per point. + public func size(forWidth width: CGFloat, in scale: CGFloat) -> CGSize { + CGSize( width: width, - height: height(forWidth: width, in: scaleFactor) + height: height(forWidth: width, in: scale) ) } @@ -139,8 +164,17 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// - parameter scaleFactor: The view/window/screen to use for pixel rounding. @MainActor public func size(forHeight height: CGFloat, in scaleFactor: ScaleFactorProviding) -> CGSize { - return CGSize( - width: width(forHeight: height, in: scaleFactor), + self.size(forHeight: height, in: scaleFactor.pixelsPerPoint) + } + + /// Returns a size of the aspect ratio with the specified `height`. The size's `width` will be rounded to the + /// nearest pixel. + /// + /// - parameter height: The desired height. + /// - parameter scale: The number of pixels per point. + public func size(forHeight height: CGFloat, in scale: CGFloat) -> CGSize { + CGSize( + width: width(forHeight: height, in: scale), height: height ) } @@ -153,15 +187,29 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// - returns: A size with the receiver's aspect ratio, no larger than the bounding size. @MainActor public func size(toFit size: CGSize, in scaleFactor: ScaleFactorProviding) -> CGSize { + self.size(toFit: size, in: scaleFactor.pixelsPerPoint) + } + + /// An "aspect-fit" function that determines the largest size of the receiver's aspect ratio that fits within a + /// size. + /// + /// - parameter size: The bounding size. + /// - parameter scale: The number of pixels per point. + /// - returns: A size with the receiver's aspect ratio, no larger than the bounding size. + public func size(toFit size: CGSize, in scale: CGFloat) -> CGSize { if size.aspectRatio <= self { // Match width, narrow the height. - let fitHeight = min(size.height, height(forWidth: size.width, in: scaleFactor)) - return CGSize(width: size.width, height: fitHeight) + CGSize( + width: size.width, + height: min(size.height, height(forWidth: size.width, in: scale)) + ) } else { // Match height, narrow the width. - let fitWidth = min(size.width, width(forHeight: size.height, in: scaleFactor)) - return CGSize(width: fitWidth, height: size.height) + CGSize( + width: min(size.width, width(forHeight: size.height, in: scale)), + height: size.height + ) } } @@ -181,7 +229,7 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { in scaleFactor: ScaleFactorProviding, layoutDirection: UIUserInterfaceLayoutDirection ) -> CGRect { - return CGRect( + CGRect( size: size(toFit: rect.size, in: scaleFactor), at: position, of: rect, @@ -205,7 +253,7 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { at position: Position, in context: (ScaleFactorProviding & LayoutDirectionProviding) ) -> CGRect { - return self.rect( + self.rect( toFit: rect, at: position, in: context, @@ -221,15 +269,29 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// - returns: A size with the receiver's aspect ratio, at least as large as the bounding size. @MainActor public func size(toFill size: CGSize, in scaleFactor: ScaleFactorProviding) -> CGSize { + self.size(toFill: size, in: scaleFactor.pixelsPerPoint) + } + + /// An "aspect-fill" function that determines the smallest size of the receiver's aspect ratio that fits a size + /// within it. + /// + /// - parameter size: The bounding size. + /// - parameter scale: The number of pixels per point. + /// - returns: A size with the receiver's aspect ratio, at least as large as the bounding size. + public func size(toFill size: CGSize, in scale: CGFloat) -> CGSize { if size.aspectRatio <= self { // Match height, expand the width. - let fillWidth = width(forHeight: size.height, in: scaleFactor) - return CGSize(width: fillWidth, height: size.height) + CGSize( + width: width(forHeight: size.height, in: scale), + height: size.height + ) } else { // Match width, expand the height. - let fillHeight = height(forWidth: size.width, in: scaleFactor) - return CGSize(width: size.width, height: fillHeight) + CGSize( + width: size.width, + height: height(forWidth: size.width, in: scale) + ) } } @@ -249,7 +311,7 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { in scaleFactor: ScaleFactorProviding, layoutDirection: UIUserInterfaceLayoutDirection ) -> CGRect { - return CGRect( + CGRect( size: size(toFill: rect.size, in: scaleFactor), at: position, of: rect, @@ -257,6 +319,7 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { layoutDirection: layoutDirection ) } + /// An "aspect-fill" function that determines the smallest rect of the receiver's aspect ratio that fits a rect /// within it. /// @@ -272,7 +335,7 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { at position: Position, in context: (ScaleFactorProviding & LayoutDirectionProviding) ) -> CGRect { - return self.rect( + self.rect( toFill: rect, at: position, in: context, @@ -288,7 +351,7 @@ extension CGSize { /// The aspect ratio of this size. public var aspectRatio: AspectRatio { - return AspectRatio(size: self) + AspectRatio(size: self) } } @@ -299,7 +362,7 @@ extension CGRect { /// The aspect ratio of this rect's size. public var aspectRatio: AspectRatio { - return AspectRatio(size: size) + AspectRatio(size: size) } // MARK: - Life Cycle diff --git a/Paralayout/PixelRounding.swift b/Paralayout/PixelRounding.swift index afd1451..63dae2f 100644 --- a/Paralayout/PixelRounding.swift +++ b/Paralayout/PixelRounding.swift @@ -27,7 +27,7 @@ public protocol ScaleFactorProviding { extension UIScreen: ScaleFactorProviding { public var pixelsPerPoint: CGFloat { - return scale + scale } } @@ -50,22 +50,6 @@ extension UIView: ScaleFactorProviding { } -extension CGFloat: ScaleFactorProviding { - - public var pixelsPerPoint: CGFloat { - return self - } - -} - -extension Int: ScaleFactorProviding { - - public var pixelsPerPoint: CGFloat { - return CGFloat(self) - } - -} - // MARK: - extension CGFloat { @@ -79,7 +63,15 @@ extension CGFloat { /// - returns: The adjusted coordinate. @MainActor public func flooredToPixel(in scaleFactor: ScaleFactorProviding) -> CGFloat { - return adjustedToPixel(scaleFactor) { floor($0) } + flooredToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the coordinate value (in points) floored to the nearest pixel, e.g. 0.6 @2x -> 0.5, not 0.0. + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func flooredToPixel(in scale: CGFloat) -> CGFloat { + adjustedToPixel(scale) { floor($0) } } /// Returns the coordinate value (in points) ceiled to the nearest pixel, e.g. 0.4 @2x -> 0.5, not 1.0. @@ -89,7 +81,15 @@ extension CGFloat { /// - returns: The adjusted coordinate. @MainActor public func ceiledToPixel(in scaleFactor: ScaleFactorProviding) -> CGFloat { - return adjustedToPixel(scaleFactor) { ceil($0) } + ceiledToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the coordinate value (in points) ceiled to the nearest pixel, e.g. 0.4 @2x -> 0.5, not 1.0. + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func ceiledToPixel(in scale: CGFloat) -> CGFloat { + adjustedToPixel(scale) { ceil($0) } } /// Returns the coordinate value (in points) rounded to the nearest pixel, e.g. 0.4 @2x -> 0.5, not 0.0. @@ -99,19 +99,29 @@ extension CGFloat { /// - returns: The adjusted coordinate. @MainActor public func roundedToPixel(in scaleFactor: ScaleFactorProviding) -> CGFloat { + roundedToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the coordinate value (in points) rounded to the nearest pixel, e.g. 0.4 @2x -> 0.5, not 0.0. + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func roundedToPixel(in scale: CGFloat) -> CGFloat { // Invoke the namespaced Darwin.round() function since round() is ambiguous (it's also a mutating instance // method). - return adjustedToPixel(scaleFactor) { Darwin.round($0) } + adjustedToPixel(scale) { Darwin.round($0) } } // MARK: - Private Methods @MainActor private func adjustedToPixel(_ scaleFactor: ScaleFactorProviding, _ adjustment: (CGFloat) -> CGFloat) -> CGFloat { - let scale = scaleFactor.pixelsPerPoint - return (scale > 0.0) ? (adjustment(self * scale) / scale) : self + adjustedToPixel(scaleFactor.pixelsPerPoint, adjustment) } + private func adjustedToPixel(_ scale: CGFloat, _ adjustment: (CGFloat) -> CGFloat) -> CGFloat { + (scale > 0.0) ? (adjustment(self * scale) / scale) : self + } } extension CGPoint { @@ -124,7 +134,16 @@ extension CGPoint { /// - returns: The adjusted coordinate. @MainActor public func flooredToPixel(in scaleFactor: ScaleFactorProviding) -> CGPoint { - return CGPoint(x: x.flooredToPixel(in: scaleFactor), y: y.flooredToPixel(in: scaleFactor)) + flooredToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the coordinate values (in points) floored to the nearest pixel, e.g. (0.6, 1.1) @2x -> (0.5, 1.0), not + /// (0.0, 1.0). + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func flooredToPixel(in scale: CGFloat) -> CGPoint { + CGPoint(x: x.flooredToPixel(in: scale), y: y.flooredToPixel(in: scale)) } /// Returns the coordinate values (in points) ceiled to the nearest pixel, e.g. (0.4, 1.1) @2x -> (0.5, 1.5), not @@ -135,7 +154,16 @@ extension CGPoint { /// - returns: The adjusted coordinate. @MainActor public func ceiledToPixel(in scaleFactor: ScaleFactorProviding) -> CGPoint { - return CGPoint(x: x.ceiledToPixel(in: scaleFactor), y: y.ceiledToPixel(in: scaleFactor)) + ceiledToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the coordinate values (in points) ceiled to the nearest pixel, e.g. (0.4, 1.1) @2x -> (0.5, 1.5), not + /// (1.0, 2.0). + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func ceiledToPixel(in scale: CGFloat) -> CGPoint { + CGPoint(x: x.ceiledToPixel(in: scale), y: y.ceiledToPixel(in: scale)) } /// Returns the coordinate values (in points) rounded to the nearest pixel, e.g. (0.4, 0.5) @2x -> (0.5, 0.5), not @@ -146,9 +174,17 @@ extension CGPoint { /// - returns: The adjusted coordinate. @MainActor public func roundedToPixel(in scaleFactor: ScaleFactorProviding) -> CGPoint { - return CGPoint(x: x.roundedToPixel(in: scaleFactor), y: y.roundedToPixel(in: scaleFactor)) + roundedToPixel(in: scaleFactor.pixelsPerPoint) } + /// Returns the coordinate values (in points) rounded to the nearest pixel, e.g. (0.4, 0.5) @2x -> (0.5, 0.5), not + /// (0.0, 1.0). + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func roundedToPixel(in scale: CGFloat) -> CGPoint { + CGPoint(x: x.roundedToPixel(in: scale), y: y.roundedToPixel(in: scale)) + } } extension CGSize { @@ -160,7 +196,15 @@ extension CGSize { /// - returns: The adjusted coordinate. @MainActor public func flooredToPixel(in scaleFactor: ScaleFactorProviding) -> CGSize { - return CGSize(width: width.flooredToPixel(in: scaleFactor), height: height.flooredToPixel(in: scaleFactor)) + flooredToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Return the size (in points) floored to the nearest pixel, e.g. (0.6, 1.1) @2x -> (0.5, 1.0), not (0.0, 1.0). + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func flooredToPixel(in scale: CGFloat) -> CGSize { + CGSize(width: width.flooredToPixel(in: scale), height: height.flooredToPixel(in: scale)) } /// Returns the size (in points) ceiled to the nearest pixel, e.g. (0.4, 1.1) @2x -> (0.5, 1.5), not (1.0, 2.0)). @@ -170,7 +214,15 @@ extension CGSize { /// - returns: The adjusted coordinate. @MainActor public func ceiledToPixel(in scaleFactor: ScaleFactorProviding) -> CGSize { - return CGSize(width: width.ceiledToPixel(in: scaleFactor), height: height.ceiledToPixel(in: scaleFactor)) + ceiledToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the size (in points) ceiled to the nearest pixel, e.g. (0.4, 1.1) @2x -> (0.5, 1.5), not (1.0, 2.0)). + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func ceiledToPixel(in scale: CGFloat) -> CGSize { + CGSize(width: width.ceiledToPixel(in: scale), height: height.ceiledToPixel(in: scale)) } /// Returns the size (in points) rounded to the nearest pixel, e.g. (0.4, 0.5) @2x -> (0.5, 0.5), not (0.0, 1.0). @@ -180,7 +232,15 @@ extension CGSize { /// - returns: The adjusted coordinate. @MainActor public func roundedToPixel(in scaleFactor: ScaleFactorProviding) -> CGSize { - return CGSize(width: width.roundedToPixel(in: scaleFactor), height: height.roundedToPixel(in: scaleFactor)) + roundedToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the size (in points) rounded to the nearest pixel, e.g. (0.4, 0.5) @2x -> (0.5, 0.5), not (0.0, 1.0). + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: The adjusted coordinate. + public func roundedToPixel(in scale: CGFloat) -> CGSize { + CGSize(width: width.roundedToPixel(in: scale), height: height.roundedToPixel(in: scale)) } } @@ -194,11 +254,19 @@ extension CGRect { /// - returns: A new rect with pixel-aligned boundaries, enclosing the original rect. @MainActor public func expandedToPixel(in scaleFactor: ScaleFactorProviding) -> CGRect { - return CGRect( - left: minX.flooredToPixel(in: scaleFactor), - top: minY.flooredToPixel(in: scaleFactor), - right: maxX.ceiledToPixel(in: scaleFactor), - bottom: maxY.ceiledToPixel(in: scaleFactor) + expandedToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the rect, outset if necessary to align each edge to the nearest pixel at the specified scale. + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: A new rect with pixel-aligned boundaries, enclosing the original rect. + public func expandedToPixel(in scale: CGFloat) -> CGRect { + CGRect( + left: minX.flooredToPixel(in: scale), + top: minY.flooredToPixel(in: scale), + right: maxX.ceiledToPixel(in: scale), + bottom: maxY.ceiledToPixel(in: scale) ) } @@ -209,11 +277,19 @@ extension CGRect { /// - returns: A new rect with pixel-aligned boundaries, enclosed by the original rect. @MainActor public func contractedToPixel(in scaleFactor: ScaleFactorProviding) -> CGRect { - return CGRect( - left: minX.ceiledToPixel(in: scaleFactor), - top: minY.ceiledToPixel(in: scaleFactor), - right: maxX.flooredToPixel(in: scaleFactor), - bottom: maxY.flooredToPixel(in: scaleFactor) + contractedToPixel(in: scaleFactor.pixelsPerPoint) + } + + /// Returns the rect, inset if necessary to align each edge to the nearest pixel at the specified scale. + /// + /// - parameter scale: The pixel scale to use (pass `0` to *not* snap to pixel). + /// - returns: A new rect with pixel-aligned boundaries, enclosed by the original rect. + public func contractedToPixel(in scale: CGFloat) -> CGRect { + CGRect( + left: minX.ceiledToPixel(in: scale), + top: minY.ceiledToPixel(in: scale), + right: maxX.flooredToPixel(in: scale), + bottom: maxY.flooredToPixel(in: scale) ) } diff --git a/ParalayoutTests/AspectRatioTests.swift b/ParalayoutTests/AspectRatioTests.swift index 3d98f39..2372c62 100644 --- a/ParalayoutTests/AspectRatioTests.swift +++ b/ParalayoutTests/AspectRatioTests.swift @@ -142,7 +142,7 @@ final class AspectRatioTests: XCTestCase { // An AspectRatio's size that fits a rect of the same aspect ratio should also be the same as the size of // that rect. - let rectToFit = ratio.rect(toFit: rect, at: position, in: scale, layoutDirection: layoutDirection) + let rectToFit = ratio.rect(toFit: rect, at: position, in: ScaleFactorProvider(scale), layoutDirection: layoutDirection) XCTAssert(rectToFit.size == ratio.size(toFit: rect.size, in: scale)) // The rect needs to be positioned as requested (within a pixel). @@ -170,7 +170,7 @@ final class AspectRatioTests: XCTestCase { let rectToFill = ratio.rect( toFill: rect, at: position, - in: scale, + in: ScaleFactorProvider(scale), layoutDirection: layoutDirection ) XCTAssert(rectToFill.size == ratio.size(toFill: rect.size, in: scale)) diff --git a/ParalayoutTests/ScaleFactorProvider.swift b/ParalayoutTests/ScaleFactorProvider.swift new file mode 100644 index 0000000..465e467 --- /dev/null +++ b/ParalayoutTests/ScaleFactorProvider.swift @@ -0,0 +1,28 @@ +// +// Copyright © 2024 Square, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//    http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Paralayout + +struct ScaleFactorProvider: ScaleFactorProviding { + + init(_ pixelsPerPoint: CGFloat) { + self.pixelsPerPoint = pixelsPerPoint + } + + let pixelsPerPoint: CGFloat + +} From f90f96af673db860c669bb0661a617cc12ceef14 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Wed, 30 Oct 2024 00:02:25 -0700 Subject: [PATCH 2/4] More --- Paralayout/AspectRatio.swift | 61 ++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/Paralayout/AspectRatio.swift b/Paralayout/AspectRatio.swift index c9c22d0..e6bc675 100644 --- a/Paralayout/AspectRatio.swift +++ b/Paralayout/AspectRatio.swift @@ -244,20 +244,21 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { /// - parameter rect: The bounding rect. /// - parameter position: The location within the bounding rect for the new rect, determining where margin(s) will /// be if the aspect ratios do not match perfectly. - /// - parameter context: The view/window/screen that provides the scale factor and effective layout direction in - /// which the rect should be positioned. + /// - parameter scale: The number of pixels per point. + /// - parameter layoutDirection: The effective layout direction of the view in which the `rect` is defined. /// - returns: A rect with the receiver's aspect ratio, strictly within the bounding rect. - @MainActor public func rect( toFit rect: CGRect, at position: Position, - in context: (ScaleFactorProviding & LayoutDirectionProviding) + in scale: CGFloat, + layoutDirection: UIUserInterfaceLayoutDirection ) -> CGRect { - self.rect( - toFit: rect, + CGRect( + size: size(toFit: rect.size, in: scale), at: position, - in: context, - layoutDirection: context.effectiveUserInterfaceLayoutDirection + of: rect, + in: scale, + layoutDirection: layoutDirection ) } @@ -320,6 +321,30 @@ public struct AspectRatio: Comparable, CustomDebugStringConvertible, Sendable { ) } + /// An "aspect-fill" function that determines the smallest rect of the receiver's aspect ratio that fits a rect + /// within it. + /// + /// - parameter rect: The bounding rect. + /// - parameter position: The location within the bounding rect for the new rect, determining where margin(s) will + /// be if the aspect ratios do not match perfectly. + /// - parameter scale: The number of pixels per point. + /// - parameter layoutDirection: The effective layout direction of the view in which the `rect` is defined. + /// - returns: A rect with the receiver's aspect ratio, strictly containing the bounding rect. + public func rect( + toFill rect: CGRect, + at position: Position, + in scale: CGFloat, + layoutDirection: UIUserInterfaceLayoutDirection + ) -> CGRect { + CGRect( + size: size(toFill: rect.size, in: scale), + at: position, + of: rect, + in: scale, + layoutDirection: layoutDirection + ) + } + /// An "aspect-fill" function that determines the smallest rect of the receiver's aspect ratio that fits a rect /// within it. /// @@ -374,6 +399,22 @@ extension CGRect { of alignmentRect: CGRect, in scaleFactor: ScaleFactorProviding, layoutDirection: UIUserInterfaceLayoutDirection + ) { + self.init( + size: newSize, + at: position, + of: alignmentRect, + in: scaleFactor.pixelsPerPoint, + layoutDirection: layoutDirection + ) + } + + fileprivate init( + size newSize: CGSize, + at position: Position, + of alignmentRect: CGRect, + in scale: CGFloat, + layoutDirection: UIUserInterfaceLayoutDirection ) { let newOrigin: CGPoint @@ -384,7 +425,7 @@ extension CGRect { case .topLeft, .topCenter, .topRight: newMinY = alignmentRect.minY case .leftCenter, .center, .rightCenter: - newMinY = (alignmentRect.midY - newSize.height / 2).roundedToPixel(in: scaleFactor) + newMinY = (alignmentRect.midY - newSize.height / 2).roundedToPixel(in: scale) case .bottomLeft, .bottomCenter, .bottomRight: newMinY = alignmentRect.maxY - newSize.height } @@ -398,7 +439,7 @@ extension CGRect { case .topLeft, .leftCenter, .bottomLeft: newMinX = alignmentRect.minX case .topCenter, .center, .bottomCenter: - newMinX = (alignmentRect.midX - newSize.width / 2).roundedToPixel(in: scaleFactor) + newMinX = (alignmentRect.midX - newSize.width / 2).roundedToPixel(in: scale) case .topRight, .rightCenter, .bottomRight: newMinX = alignmentRect.maxX - newSize.width } From 95aaa86a25d9d6f3c2b855f2f79668e35fea93c8 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Wed, 30 Oct 2024 00:05:35 -0700 Subject: [PATCH 3/4] Delete unused method --- Paralayout/PixelRounding.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Paralayout/PixelRounding.swift b/Paralayout/PixelRounding.swift index 63dae2f..e18e223 100644 --- a/Paralayout/PixelRounding.swift +++ b/Paralayout/PixelRounding.swift @@ -114,11 +114,6 @@ extension CGFloat { // MARK: - Private Methods - @MainActor - private func adjustedToPixel(_ scaleFactor: ScaleFactorProviding, _ adjustment: (CGFloat) -> CGFloat) -> CGFloat { - adjustedToPixel(scaleFactor.pixelsPerPoint, adjustment) - } - private func adjustedToPixel(_ scale: CGFloat, _ adjustment: (CGFloat) -> CGFloat) -> CGFloat { (scale > 0.0) ? (adjustment(self * scale) / scale) : self } From 81a4b714a22ecc45e78094cb01f6cffd12513f8c Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Wed, 30 Oct 2024 00:30:25 -0700 Subject: [PATCH 4/4] labelCapInsets --- Paralayout/UIFont+CapInsets.swift | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Paralayout/UIFont+CapInsets.swift b/Paralayout/UIFont+CapInsets.swift index e18d8aa..835866e 100644 --- a/Paralayout/UIFont+CapInsets.swift +++ b/Paralayout/UIFont+CapInsets.swift @@ -63,15 +63,23 @@ extension UIFont { /// - returns: The insets. @MainActor public func labelCapInsets(in scaleFactor: ScaleFactorProviding) -> LabelCapInsets { + labelCapInsets(in: scaleFactor.pixelsPerPoint) + + } + + /// The space above and below the receiver's capHeight and baseline, as displayed in a UILabel. + /// - parameter scale: The number of pixels per point. + /// - returns: The insets. + public func labelCapInsets(in scale: CGFloat) -> LabelCapInsets { // One would expect ceil(ascender) - floor(descender) so that the baseline would land on a pixel boundary, but // sadly no--this is what `UILabel.sizeToFit()` does. - let lineHeight = (ascender - descender).ceiledToPixel(in: scaleFactor) - + let lineHeight = (ascender - descender).ceiledToPixel(in: scale) + // Based on experiments with SFUIText and Helvetica Neue, this is how the text is positioned within a label. - let bottomInset = lineHeight - ascender.roundedToPixel(in: scaleFactor) - let topInset = lineHeight - (bottomInset + capHeight.roundedToPixel(in: scaleFactor)) - + let bottomInset = lineHeight - ascender.roundedToPixel(in: scale) + let topInset = lineHeight - (bottomInset + capHeight.roundedToPixel(in: scale)) + return LabelCapInsets(top: topInset, bottom: bottomInset) } - + }