Responsive points and text for Swift, SwiftUI, UIKit, AppKit and Metal — the Apple port of AppDimens Dynamic for Android, with the same names, the same formulas and the same usage.
Install · SwiftUI · UIKit / AppKit · Metal · Strategies · Documentation · Beginner guide
Write 16.sdp and the value scales with the current window — exactly like 16.sdp in Jetpack Compose:
Text("Hello")
.font(.system(size: 16.ssp))
.padding(16.sdp)
.frame(width: 100.wdp, height: 48.hdp)| Android (Compose) | Apple (SwiftUI / UIKit) | Meaning |
|---|---|---|
16.sdp → Dp |
16.sdp → CGFloat |
smallest window side / 300 |
16.hdp, 16.wdp |
16.hdp, 16.wdp |
window height / width |
16.ssp, 16.sem |
16.ssp, 16.sem |
text with / without Dynamic Type |
16.sdp(context) → px |
16.sdp(configuration) → points |
explicit window (tests, background work) |
16.sdpPx |
16.sdpPx |
pixels (points × displayScale) |
suffixes a, i, ia |
16.sdpa, 16.sdpi, 16.sdpia |
aspect ratio, ignore multi-window |
32.sdpPh, 50.hdpLw … |
32.sdpPh, 50.hdpLw … |
orientation inverters |
80.sdpRotate(50) |
80.sdpRotate(50) |
facilitators: Rotate, Mode, Qualifier, Screen (+Px, Plain) |
16.scaledDp().screen(…) |
16.scaledDp().screen(…) |
conditional builder, same priority rules |
16.pwsdp, 16.asdp, 16.fsdp … |
identical | every satellite strategy |
AppDimensProvider { } |
AppDimensProvider { } / .appDimens() |
per-window provider |
LocalConfiguration |
@Environment(\.appDimensConfiguration) |
window snapshot |
The design baseline is 300 × 533 points (Android's 300 × 533 dp). Results are bit-for-bit the Android formulas, computed in Double (the test suite cross-checks every factor against the Kotlin Float implementation).
Xcode: File › Add Package Dependencies… → https://github.com/bodenberg/appdimens-ios.
// Package.swift
.package(url: "https://github.com/bodenberg/appdimens-ios", from: "3.2.0")
.target(name: "App", dependencies: [
.product(name: "AppDimensDynamic", package: "appdimens-ios"), // core + strategies + auto + resize + units + metal
.product(name: "AppDimensPower", package: "appdimens-ios"), // add satellites you use
])| Product | Android artifact | Contents |
|---|---|---|
AppDimens |
appdimens-dynamic |
core: sdp/hdp/wdp/ssp/sem…, builders, providers |
AppDimensDynamic |
BOM-style aggregate | core + strategies + auto + resize + units + Metal |
AppDimensPercent, AppDimensPower, AppDimensFluid, AppDimensAuto, AppDimensDensity, AppDimensDiagonal, AppDimensFill, AppDimensFit, AppDimensInterpolated, AppDimensLogarithmic, AppDimensPerimeter |
appdimens-dynamic-<strategy> |
satellite catalogs (p*, pw*, f*, a*, d*, dg*, fl*, ft*, i*, log*, pr*) |
AppDimensResize, AppDimensUnits |
-resize, -units |
auto-resize ladders, physical units |
AppDimensStrategies |
— | runtime strategy selection, DimensFactors |
AppDimensMetal |
— | GPU uniforms + MSL helpers |
Requirements: Swift 5.9+ (Xcode 15+), iOS 13, macOS 10.15, tvOS 13, watchOS 7, visionOS 1. Linux is supported for the platform-independent API.
Install the provider once per window (Android AppDimensProvider):
import SwiftUI
import AppDimensDynamic
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView().appDimens() // or AppDimensProvider { ContentView() }
}
}
}
struct ContentView: View {
var body: some View {
VStack(spacing: 12.sdp) {
Text("Title").font(.system(size: 22.ssp, weight: .bold))
Text("Body").font(.system(size: 16.ssp))
RoundedRectangle(cornerRadius: 12.sdp)
.frame(width: 100.wdp, height: 48.hdp)
}
.padding(16.sdp)
}
}The provider measures the full window (safe-area insets — including the keyboard — are added back, so showing the keyboard never rescales the UI), reads displayScale and Dynamic Type, and publishes one DimensConfiguration to:
- the ambient store read by
16.sdp— on iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1+ everybodythat reads16.sdpis re-evaluated automatically on rotation, resize, Split View or Dynamic Type changes (Swift Observation — the ComposeLocalConfigurationbehaviour); - the environment value
\.appDimensConfiguration— the per-window, every-OS-version path.
Multi-window apps and iOS 13–16: read the environment so each view resolves against its own window and is invalidated by SwiftUI on every OS version:
struct Card: View {
@Environment(\.appDimensConfiguration) private var dimens
@ScaledDimension(16) private var padding // sdp
@ScaledDimension(text: 18) private var titleSize // ssp
@ScaledDimension(24, strategy: .power) private var gap // pwsdp
var body: some View {
VStack(spacing: gap) {
Text("Card").font(.system(size: titleSize))
Divider().frame(width: 120.wdp(dimens)) // explicit window → Double
}
.padding(padding)
}
}Previews and snapshot tests can pin a window: .appDimensConfiguration(DimensConfiguration(screenWidth: 390, screenHeight: 844, displayScale: 3)).
import UIKit
import AppDimensDynamic
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
AppDimens.startAutomaticUpdates() // tracks the key window: rotation, Split View, Stage Manager, Dynamic Type
}
final class ProfileCell: UICollectionViewCell {
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 12.sdp
titleLabel.font = .ssp(17, weight: .semibold) // UIFont.systemFont sized with ssp
heightAnchor.constraint(equalToConstant: 72.hdp).isActive = true
}
}
// Resolve against the window hosting a specific view (multi-window iPad):
let radius = 12.sdp(in: view)macOS (AppKit) uses the same calls: AppDimens.startAutomaticUpdates() follows the key window (resize, full screen, screen and backing-scale changes) and NSFont.ssp(_:weight:) is available.
Without automatic updates, call AppDimens.refresh(from: view) from viewWillTransition(to:with:), or set any snapshot yourself with AppDimens.update(_:). AppDimens.observe { configuration in … } notifies you of every change (e.g. to invalidate a layout or a Metal buffer).
Every accessor also takes a configuration and returns Double points — deterministic, allocation-free and safe on any thread:
let c = DimensConfiguration(screenWidth: 390, screenHeight: 844, displayScale: 3, fontScale: 1)
16.sdp(c) // 20.8
16.hdp(c) // 45.01
16.ssp(c) // 20.8 × fontScale
16.sdpPx(c) // 62.4 pixels
16.pwsdp(c) // power strategy16.sdpa // aspect-ratio-aware curve
32.hdpi // ignore multi-window (raw value when the window is constrained)
16.sspia // both
32.sdpPh // smallest width → height in portrait
50.hdpLw // height → width in landscape
50.wdpLh // width → height in landscape
16.sei // sem in pixels
16.sdpPx, 16.sdpPxPh, 16.sspPxa // pixel variants (Android spellings and <name>Px)Inverters: phToLw, pwToLh, lhToPw, lwToPh, swToLh, swToLw, swToPh, swToPw (.default never swaps — hdp is always the window height).
80.sdpRotate(50) // 50 in landscape, 80 otherwise
80.sdpRotate(50, orientation: .portrait)
30.sdpMode(200, .television) // Apple TV
16.sdpQualifier(24, .smallWidth, 600) // ≥ 600 pt smallest width
16.sdpScreen(40, .television, .width, 1280) // mode AND qualifier
16.sspRotate(20, fontScale: false) // text, options as on Android
16.sdpRotate(24, finalQualifierResolver: .width, applyAspectRatio: true)
resolved.sdpRotatePlain(50) // receiver passes through unchanged when not matching
80.sdpRotatePx(50) // pixelsEvery facilitator exists for sdp/hdp/wdp/ssp/hsp/wsp and for every satellite prefix (pwsdpMode, asspScreen …), ambient (CGFloat) or with a trailing configuration (Double).
Same priority rules as Android DimenScaled: 1 mode + qualifier, 2 mode, 3 qualifier, 4 orientation; ties break by the higher qualifier threshold.
let padding = 16.scaledDp()
.screen(.television, customValue: 40)
.screen(.smallWidth, 600, customValue: 24)
.screen(.smallWidth, 840, customValue: 32)
.screen(.landscape, customValue: 12)
.applyAspectRatio()
padding.sdp // CGFloat, ambient
padding.hdp(c) // Double, explicit window
let title = 20.scaledSp().screen(.smallWidth, 600, customValue: 28)
Text("Title").font(.system(size: title.ssp))
16.powerDp().screen(.landscape, customValue: 12).sdp // any strategy: powerDp(), autoDp(), fluidSp() …
16.dimen(.logarithmic).sdp // strategy chosen at run time| Strategy | Prefix | Formula (d = resolved axis, default smallest width) |
Typical use |
|---|---|---|---|
| Scaled | — | v·d/300 |
default for spacing and text |
| Percent | p |
v·d/300 (+ literal 25.spaceW, 10.space(200)) |
proportional layouts |
| Power | pw |
v·(d/300)^0.75 |
damped growth on tablets |
| Logarithmic | log |
v·(1 ± 0.4·ln(d/300)) |
strong damping |
| Auto | a |
linear to 480, logarithmic beyond | phones linear, tablets damped |
| Fluid | f |
v × 0.8…1.2 over 320…768 |
bounded typography |
| Interpolated | i |
v·(1 + (d/300 − 1)/2) |
half-way between fixed and scaled |
| Diagonal | dg |
v·√(w²+h²)/611.63 |
screen-size aware icons |
| Perimeter | pr |
v·(w+h)/833 |
balanced 2-axis growth |
| Fit / Fill | ft / fl |
v·min/max(sm/300, lg/533) |
contain / cover |
| Density | d |
v·displayScale |
pixel-density aware strokes |
Each satellite has the full catalog: 16.pwsdp, 16.pwhdpLw, 16.pwsspa, 30.pwsdpMode(…), 16.powerDp().screen(…). Runtime selection: 16.dynamic(.power) or DynamicDimens.resolve(16, strategy: .power, configuration: c). Details: Strategy catalog, Mathematics.
import AppDimensMetal
let library = try AppDimensMetal.makeLibrary(device: device, additionalSource: myShaderSource)
encoder.setAppDimensUniforms(index: 1) // ambient window, setVertexBytes + setFragmentBytes
// or a persistent buffer:
let buffer = AppDimensMetal.makeBuffer(device: device)!
let token = AppDimens.observe { c in AppDimensMetal.update(buffer, configuration: c) }vertex VertexOut vertex_main(uint vid [[vertex_id]], constant AppDimensUniforms& u [[buffer(1)]]) {
float corner = appdimens_sdp(u, 12.0); // same value as 12.sdp on the CPU
float stroke = appdimens_px(u, appdimens_power(u, 2.0));
float2 ndc = appdimens_ndc(u, float2(appdimens_wdp(u, 20.0), appdimens_hdp(u, 40.0)));
…
}AppDimensUniforms (96 bytes) carries every strategy factor plus display scale, font scale, aspect ratio and the multi-window flag; AppDimensUniforms.metalSource contains the matching MSL struct and helpers. See Metal guide.
| Path | Cost per value | Notes |
|---|---|---|
16.sdp(configuration) |
< 1 ns | factors precomputed per window; inlined multiply |
DimensFactors(c).resolve(_:strategy:) |
< 1 ns | hot loops, buffers (resolve(&array, strategy:)) |
16.sdp (ambient) |
one uncontended os_unfair_lock (+ ~20 ns Observation registration on the main thread once a SwiftUI provider is installed) |
thread-safe, coherent snapshot |
DimensConfiguration(...) |
~45 ns | all log/pow/sqrt evaluated once here |
No caches to invalidate and no allocation on any resolution path. On the default smallest-width path every strategy is a precomputed factor; non-default axes of power/logarithmic/auto evaluate one pow/log per call. Run swift test -c release --filter PerformanceTests for local numbers; see Performance.
sdp/hdp/wdpalways describe the window, never the view they are used in. UseGeometryReaderfor container-relative layout.ssp/hsp/wspmultiply by the Dynamic Type scale (body size ÷ 17 pt);sem/hem/wemignore it. SwiftUI.system(size:)fonts are fixed-size, so the scale is applied exactly once.*ivariants return the unscaled value when the window is constrained (iPad Split View / Slide Over / Stage Manager, resized Mac window).- Invalid inputs (zero, negative, NaN) fall back to the 300 × 533 reference instead of crashing.
- Beginner guide
- Documentation hub — API · Apple integration · Metal · Strategies · Mathematics · Performance · Migration · Android parity · Troubleshooting
- Changelog
swift test # all platforms incl. Linux
swift test -c release --filter PerformanceTests
python3 scripts/generate_catalogs.py # regenerate the catalogs (CI checks they are current)
python3 .github/scripts/validate_repository.pyLicensed under the Apache License 2.0.