Skip to content
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
8 changes: 3 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@ Organizing rule: **one platform = one place.** Everything outside `UIKit/` and `
| `Core/ScaleType.swift` | Protocols (`ScreenScalable`, `ScreenUtilConfigurable`, `ScreenDimensionProvider`) + `ScaleType` |
| `Core/ScalingLimits.swift` | `ScalingLimits` (`.default`/`.strict`/`.relaxed`) |
| `Core/ScreenMetrics.swift` | `ScreenMetrics` snapshot |
| `Internal/Atomic.swift` | `Atomic`, `UnsafeAtomicDouble` — **internal**, lock-backed property wrappers |
| `Internal/ScaleFactorCache.swift` | Captured factors for batch/fast scaling + `cgFloatValue(_:)` Numeric→CGFloat bridge |
| `Internal/ScreenDimensionsCache.swift` | TTL cache + rotation invalidation for dimensions |
| `Internal/SafeAreaCacheManager.swift` | TTL cache + invalidation for safe-area insets |
| `Internal/ScaleFactorCache.swift` | Captured factors for FastScale/BatchScaler + `cgFloatValue(_:)` Numeric→CGFloat bridge |
| `Internal/Snapshot.swift` | Immutable, atomically-published snapshot of nonisolated-readable scale/metric state |
| `Internal/Log.swift` | Unified-logging endpoints (`os.Logger`) — internal `Log(_:_:level:)` |
| `Metrics/ScreenDimensions.swift` | Platform dimensions snapshot (UIKit-gated reader) |
| `Metrics/SafeAreaInsets.swift` | Platform safe-area snapshot (UIKit-gated reader) |
| `Metrics/DeviceType.swift` | Device/platform classification |
| `Scaling/Numeric+Scaling.swift` | `Int/Float/Double/CGFloat` `.w/.h/.sp/.r/.sw/.sh` |
| `Scaling/CGGeometry+Scaling.swift` | `CGSize/CGPoint/CGRect` scaling (cross-platform) |
Expand Down
80 changes: 56 additions & 24 deletions Sources/ScreenUtil/Core/ScreenUtil.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable
private init() {}

/// One consistent, nonisolated snapshot read.
internal var _snapshot: Snapshot { snapshot.load(ordering: .acquiring) }
@usableFromInline internal var _snapshot: Snapshot { snapshot.load(ordering: .acquiring) }

// MARK: Configuration (main actor)

Expand All @@ -54,7 +54,7 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable
@MainActor
private func rebuildSnapshot() {
let dims = ScreenDimensions.current
let safe = SafeAreaInsets.current
let safe = Self.nativeSafeArea()
let device = DeviceType.current
let design = configuration.designSize

Expand All @@ -71,13 +71,42 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable
screenWidth: dims.width,
screenHeight: dims.height,
screenScale: dims.scale,
safeArea: safe,
safeAreaTop: safe.top,
safeAreaBottom: safe.bottom,
safeAreaLeft: safe.left,
safeAreaRight: safe.right,
statusBarHeight: safe.statusBar,
deviceType: device
),
ordering: .releasing
)
}

/// Native window safe-area insets + status bar height, captured at rebuild
/// (rotation / scene-activate). Returns 0 when no active window is available.
#if canImport(UIKit) && !os(watchOS)
@MainActor
private static func nativeSafeArea() -> (top: CGFloat, bottom: CGFloat, left: CGFloat, right: CGFloat, statusBar: CGFloat) {
let scenes = UIApplication.shared.connectedScenes
let scene = (scenes.first { $0.activationState == .foregroundActive }
?? scenes.first) as? UIWindowScene
guard let scene, let window = scene.keyWindow ?? scene.windows.first else {
return (0, 0, 0, 0, 0)
}
let i = window.safeAreaInsets
#if os(iOS)
let status = scene.statusBarManager?.statusBarFrame.height ?? 0
#else
let status: CGFloat = 0 // tvOS has no status bar
#endif
return (i.top, i.bottom, i.left, i.right, status)
}
#else
private static func nativeSafeArea() -> (top: CGFloat, bottom: CGFloat, left: CGFloat, right: CGFloat, statusBar: CGFloat) {
(0, 0, 0, 0, 0)
}
#endif

@MainActor
private func startObservingIfNeeded() {
guard !isObserving else { return }
Expand All @@ -99,8 +128,8 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable

// MARK: Scaling (nonisolated)

@inline(__always)
private func scaleFactor(for scaleType: ScaleType) -> CGFloat {
@inlinable @inline(__always)
func scaleFactor(for scaleType: ScaleType) -> CGFloat {
let s = _snapshot
switch scaleType {
case .width: return s.scaleWidth
Expand All @@ -110,16 +139,19 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable
}
}

@inline(__always)
@inlinable @inline(__always)
public func scale(for value: CGFloat, scaleType: ScaleType) -> CGFloat {
guard value.isFinite else {
Log(.core, "Invalid input value \(value) for scale type \(scaleType)", level: .warning)
return 0
}
guard value.isFinite else { return Self.invalidScaleResult(value, scaleType) }
return value * scaleFactor(for: scaleType)
}

@inline(__always)
@usableFromInline
static func invalidScaleResult(_ value: CGFloat, _ scaleType: ScaleType) -> CGFloat {
Log(.core, "Invalid input value \(value) for scale type \(scaleType)", level: .warning)
return 0
}

@inlinable @inline(__always)
public func fastScale(for value: CGFloat, scaleType: ScaleType) -> CGFloat {
return value * scaleFactor(for: scaleType)
}
Expand All @@ -128,11 +160,11 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable

public var screenWidth: CGFloat { _snapshot.screenWidth }
public var screenHeight: CGFloat { _snapshot.screenHeight }
public var safeAreaTop: CGFloat { _snapshot.safeArea.top }
public var safeAreaBottom: CGFloat { _snapshot.safeArea.bottom }
public var safeAreaLeft: CGFloat { _snapshot.safeArea.left }
public var safeAreaRight: CGFloat { _snapshot.safeArea.right }
public var statusBarHeight: CGFloat { _snapshot.safeArea.statusBarHeight }
public var safeAreaTop: CGFloat { _snapshot.safeAreaTop }
public var safeAreaBottom: CGFloat { _snapshot.safeAreaBottom }
public var safeAreaLeft: CGFloat { _snapshot.safeAreaLeft }
public var safeAreaRight: CGFloat { _snapshot.safeAreaRight }
public var statusBarHeight: CGFloat { _snapshot.statusBarHeight }
public var scaleWidth: CGFloat { _snapshot.scaleWidth }
public var scaleHeight: CGFloat { _snapshot.scaleHeight }
public var scaleText: CGFloat { _snapshot.scaleText }
Expand All @@ -144,17 +176,17 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable
width: s.screenWidth,
height: s.screenHeight,
scale: s.screenScale,
safeAreaInsets: (s.safeArea.top, s.safeArea.bottom, s.safeArea.left, s.safeArea.right),
statusBarHeight: s.safeArea.statusBarHeight
safeAreaInsets: (s.safeAreaTop, s.safeAreaBottom, s.safeAreaLeft, s.safeAreaRight),
statusBarHeight: s.statusBarHeight
)
}
}

public extension ScreenUtil {
@inline(__always) func w(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .width) }
@inline(__always) func h(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .height) }
@inline(__always) func sp(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .text) }
@inline(__always) func r(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .radius) }
@inline(__always) func sw(_ percentage: CGFloat) -> CGFloat { screenWidth * (percentage / 100.0) }
@inline(__always) func sh(_ percentage: CGFloat) -> CGFloat { screenHeight * (percentage / 100.0) }
@inlinable @inline(__always) func w(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .width) }
@inlinable @inline(__always) func h(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .height) }
@inlinable @inline(__always) func sp(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .text) }
@inlinable @inline(__always) func r(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .radius) }
@inlinable @inline(__always) func sw(_ percentage: CGFloat) -> CGFloat { screenWidth * (percentage / 100.0) }
@inlinable @inline(__always) func sh(_ percentage: CGFloat) -> CGFloat { screenHeight * (percentage / 100.0) }
}
1 change: 1 addition & 0 deletions Sources/ScreenUtil/Debug/ScreenUtilDebug.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public enum ScreenUtilDebug {
#if DEBUG
/// Runs `operation` `iterations` times and returns the last result alongside the average execution time.
public static func measurePerformance<T>(_ operation: () -> T, iterations: Int = 10000) -> (result: T, averageTime: TimeInterval) {
precondition(iterations > 0, "iterations must be > 0")
var totalTime: TimeInterval = 0
var result: T!

Expand Down
22 changes: 12 additions & 10 deletions Sources/ScreenUtil/Internal/ScaleFactorCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import Foundation
import CoreGraphics

internal struct ScaleFactorCache: Sendable {
let widthScale: CGFloat
let heightScale: CGFloat
let textScale: CGFloat
let radiusScale: CGFloat
@usableFromInline
struct ScaleFactorCache: Sendable {
@usableFromInline let widthScale: CGFloat
@usableFromInline let heightScale: CGFloat
@usableFromInline let textScale: CGFloat
@usableFromInline let radiusScale: CGFloat

@usableFromInline
init(from screenUtil: ScreenUtil) {
let s = screenUtil._snapshot
self.widthScale = s.scaleWidth
Expand All @@ -23,22 +25,22 @@ internal struct ScaleFactorCache: Sendable {
self.radiusScale = min(s.scaleWidth, s.scaleHeight)
}

@inline(__always)
@inlinable @inline(__always)
func scaleWidth(_ value: CGFloat) -> CGFloat {
return value * widthScale
}

@inline(__always)
@inlinable @inline(__always)
func scaleHeight(_ value: CGFloat) -> CGFloat {
return value * heightScale
}

@inline(__always)
@inlinable @inline(__always)
func scaleText(_ value: CGFloat) -> CGFloat {
return value * textScale
}

@inline(__always)
@inlinable @inline(__always)
func scaleRadius(_ value: CGFloat) -> CGFloat {
return value * radiusScale
}
Expand All @@ -60,7 +62,7 @@ internal func cgFloatValue<T: Numeric>(_ value: T) -> CGFloat {
return CGFloat(v)
default:
if let v = value as? any BinaryInteger {
return CGFloat(Int(v))
return CGFloat(v)
}
if let v = value as? any BinaryFloatingPoint {
return CGFloat(Double(v))
Expand Down
40 changes: 29 additions & 11 deletions Sources/ScreenUtil/Internal/Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@
import CoreGraphics
import Atomics

@usableFromInline
final class Snapshot: AtomicReference, Sendable {
let scaleWidth: CGFloat
let scaleHeight: CGFloat
let scaleText: CGFloat
@usableFromInline let scaleWidth: CGFloat
@usableFromInline let scaleHeight: CGFloat
@usableFromInline let scaleText: CGFloat
let screenWidth: CGFloat
let screenHeight: CGFloat
let screenScale: CGFloat
let safeArea: SafeAreaInsets
// Native safe-area values, captured from the window at rebuild time (0 when unavailable).
let safeAreaTop: CGFloat
let safeAreaBottom: CGFloat
let safeAreaLeft: CGFloat
let safeAreaRight: CGFloat
let statusBarHeight: CGFloat
let deviceType: DeviceType

init(
Expand All @@ -26,7 +32,11 @@ final class Snapshot: AtomicReference, Sendable {
screenWidth: CGFloat,
screenHeight: CGFloat,
screenScale: CGFloat,
safeArea: SafeAreaInsets,
safeAreaTop: CGFloat,
safeAreaBottom: CGFloat,
safeAreaLeft: CGFloat,
safeAreaRight: CGFloat,
statusBarHeight: CGFloat,
deviceType: DeviceType
) {
self.scaleWidth = scaleWidth
Expand All @@ -35,13 +45,21 @@ final class Snapshot: AtomicReference, Sendable {
self.screenWidth = screenWidth
self.screenHeight = screenHeight
self.screenScale = screenScale
self.safeArea = safeArea
self.safeAreaTop = safeAreaTop
self.safeAreaBottom = safeAreaBottom
self.safeAreaLeft = safeAreaLeft
self.safeAreaRight = safeAreaRight
self.statusBarHeight = statusBarHeight
self.deviceType = deviceType
}

static let `default` = Snapshot(
scaleWidth: 1, scaleHeight: 1, scaleText: 1,
screenWidth: 375, screenHeight: 812, screenScale: 2,
safeArea: .zero, deviceType: .platformDefault
)
static let `default`: Snapshot = {
let dims = ScreenDimensions.platformDefault
return Snapshot(
scaleWidth: 1, scaleHeight: 1, scaleText: 1,
screenWidth: dims.width, screenHeight: dims.height, screenScale: dims.scale,
safeAreaTop: 0, safeAreaBottom: 0, safeAreaLeft: 0, safeAreaRight: 0, statusBarHeight: 0,
deviceType: .platformDefault
)
}()
}
70 changes: 0 additions & 70 deletions Sources/ScreenUtil/Metrics/SafeAreaInsets.swift

This file was deleted.

Loading
Loading