Responsive points and Dynamic Type for SwiftUI and UIKit, directly inspired by AppDimens Dynamic for Android.
Install · SwiftUI · UIKit · Strategies · Full documentation · Beginner guide
Important
AppDimens reads one active window/scene configuration. Child view bounds never redefine sdp, so the same value remains consistent throughout a window.
Complete modular Apple port of AppDimens Dynamic: the principal artifact, all thirteen Android satellite artifacts, an aggregate module, and the Apple-specific Metal bridge.
sdp / wdp / hdp always use the current window/scene configuration, exactly as Android uses Configuration/LocalConfiguration. A child view never becomes a new scaling reference. Container-relative sizing is not mixed with these APIs.
Baseline: 300 points, reference aspect ratio: 1.78.
In Xcode choose File › Add Package Dependencies… and enter:
https://github.com/bodenberg/appdimens-ios
Select version 3.1.6, then choose AppDimensDynamic for everything, AppDimens for the principal artifact, or an individual satellite product. With SwiftPM:
.package(url: "https://github.com/bodenberg/appdimens-ios", exact: "3.1.6")
// .product(name: "AppDimens", package: "appdimens-ios")Install once at the WindowGroup root. No width, height, display scale, font scale or device mode is passed by the app:
import SwiftUI
import AppDimens
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView().appDimens()
}
}
}
struct ContentView: View {
@ScaledDimension(16) private var spacing
@ScaledDimension(100, .width) private var width
@ScaledDimension(text: 16) private var textSize
var body: some View {
VStack(spacing: spacing) {
Text("AppDimens").font(.system(size: textSize))
}.frame(width: width)
}
}The provider observes the root window container plus SwiftUI displayScale and sizeCategory. All descendants share one DimensConfiguration.
import UIKit
import AppDimens
@MainActor
func configure(view: UIView) {
view.layer.cornerRadius = 10.sdp(in: view)
let width = 100.wdp(in: view)
}The view is used only to locate its UIWindowScene; its own bounds never affect sdp, wdp or hdp. Two child views in the same window therefore resolve identical values.
Explicit configuration remains available for tests and non-UI code:
let configuration = DimensConfiguration(screenWidth: 390, screenHeight: 844)
let spacing = 10.sdp(configuration) // 13| Android | Swift deterministic | SwiftUI |
|---|---|---|
16.sdp |
16.sdp(configuration) |
@ScaledDimension(16) |
100.wdp |
100.wdp(configuration) |
@ScaledDimension(100, .width) |
48.hdp |
48.hdp(configuration) |
@ScaledDimension(48, .height) |
16.ssp |
16.ssp(configuration) |
@ScaledDimension(text: 16) |
16.hsp |
16.hsp(configuration) |
@ScaledDimension(text: 16, .height) |
16.wsp |
16.wsp(configuration) |
@ScaledDimension(text: 16, .width) |
16.sem |
16.sem(configuration) |
@ScaledDimension(text: 16, fontScale: false) |
suffix a |
sdpa / aspectRatio: true |
aspectRatio: true |
suffix i |
sdpi / ignoreMultiWindow: true |
ignoreMultiWindow: true |
suffix ia |
sdpia |
both flags |
DpQualifier ports SMALL_WIDTH, HEIGHT, and WIDTH. All eight principal inverter mappings are present: phToLw, pwToLh, lhToPw, lwToPh, swToLh, swToLw, swToPh, and swToPw.
let value = AppDimens.dp(16, configuration: configuration,
qualifier: .smallWidth, inverter: .swToLw)let responsive = AppDimens.qualified(16, qualifiedValue: 24,
configuration: configuration, qualifier: .width, minimum: 600)
let rotated = AppDimens.rotate(16, rotationValue: 12,
configuration: configuration, orientation: .landscape)
let built = 16.scaledDp
.screen(24, qualifier: .width, minimum: 600)
.rotate(12, .landscape)
.aspectRatio()
.resolve(configuration)The engine is stateless, allocation-free and @inlinable on calculation paths. Configuration is captured once at the SwiftUI root or resolved from the UIKit window; it is not read from every child view. No global dictionary, lock, notification observer or per-value cache is required for an O(1) multiplication. SwiftUI's Hashable environment value invalidates consumers only when window metrics actually change; install exactly one provider. For a render loop, create DimensFactors once per changed configuration and call resolve(_:strategy:) to reuse its ratios.
Note
watchOS 7+ supports the deterministic core, strategies, units, resize and aggregate imports. The window-level SwiftUI provider is excluded, and the Metal satellite becomes an empty compatibility module because those contracts are unavailable on watchOS.
import AppDimensDynamic // every module
// or import AppDimens, AppDimensAuto, AppDimensPercent, etc.The package provides AppDimens, AppDimensAuto, AppDimensDensity, AppDimensDiagonal, AppDimensFill, AppDimensFit, AppDimensFluid, AppDimensInterpolated, AppDimensLogarithmic, AppDimensPercent, AppDimensPerimeter, AppDimensPower, AppDimensResize, AppDimensUnits, AppDimensMetal, and the aggregate AppDimensDynamic.
let power = 16.powerDp(configuration)
let fluid = 16.fluidDp(configuration)
let halfWidth = 100.percentDp(configuration, percent: 50, qualifier: .width)
let any = 16.dynamic(.diagonal, configuration)All strategies accept StrategyOptions for qualifier, inverter, aspect ratio, multi-window, sensitivity, percent, power, interpolation, and fluid ranges.
let value = 16.autoScaledDp
.qualifier(24, .width, minimum: 600)
.rotate(14, .landscape)
.mode(28, .television)
.resolve(configuration)let steps = DimensResize.steps(minimum: 12, maximum: 48, step: 1)
let fitted = DimensResize.largestFitting(steps) { measure($0) <= available }
let millimeters = DimensUnits.points(10, from: .millimeter, configuration: configuration)AppDimensUniforms is a fixed 64-byte CPU/GPU ABI. Create one shared MTLBuffer and update it only when the window configuration changes.
See the complete module matrix and principal parity audit.
Individual strategy products reexport the shared strategy API, so applications can express intent in their dependency graph while using the same tested engine.
| Need | Product | Import |
|---|---|---|
| Complete library | AppDimensDynamic |
import AppDimensDynamic |
| Principal scaled/plain API | AppDimens |
import AppDimens |
| Adaptive conditions | AppDimensAuto |
import AppDimensAuto |
| All mathematical curves | AppDimensStrategies |
import AppDimensStrategies |
| One curve only | corresponding product | e.g. import AppDimensPower |
| Auto-resize math | AppDimensResize |
import AppDimensResize |
| Physical units | AppDimensUnits |
import AppDimensUnits |
| GPU uniforms | AppDimensMetal |
import AppDimensMetal |
Individual strategy products reexport the shared strategy API, so applications can express intent in their dependency graph while using the same tested engine.
import SwiftUI
import AppDimensDynamic
@main
struct AppDimensDemo: App {
var body: some Scene {
WindowGroup {
Dashboard()
.appDimens() // exactly once per scene
}
}
}
struct Dashboard: View {
@ScaledDimension(16) private var spacing
@ScaledDimension(12, aspectRatio: true) private var radius
@ScaledDimension(text: 17) private var bodyText
@Environment(\.appDimensConfiguration) private var configuration
var body: some View {
let cardWidth = 320.fitDp(configuration)
let horizontalInset = 100.percentDp(
configuration,
percent: 4,
qualifier: .width
)
ScrollView {
VStack(spacing: spacing) {
Text("Dashboard")
.font(.system(size: bodyText, weight: .bold))
RoundedRectangle(cornerRadius: radius)
.frame(width: cardWidth, height: 180)
}
.padding(.horizontal, horizontalInset)
}
}
}Android Compose reads LocalConfiguration, a shared window configuration. SwiftUI has no identical public value containing every required metric, so .appDimens() creates the equivalent at the scene root. It does not require dimensions from the caller and does not inspect each child view.
import UIKit
import AppDimensDynamic
final class CardViewController: UIViewController {
private let card = UIView()
private let titleLabel = UILabel()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let configuration = DimensConfiguration.current(in: view)
let inset = CGFloat(16.sdp(configuration))
let radius = CGFloat(12.sdpa(configuration))
card.layer.cornerRadius = radius
card.directionalLayoutMargins = .init(
top: inset,
leading: inset,
bottom: inset,
trailing: inset
)
titleLabel.font = .systemFont(
ofSize: CGFloat(17.ssp(configuration))
)
}
}current(in:) is main-actor isolated because UIKit scene and trait access must occur on the main thread.
For base value v, selected window axis d, baseline b = 300, aspect ratio ar, and reference ratio 1.78:
scaled = v × d / 300
aspect = v × [1 + (d - 300) × (1/300 + k × ln(ar/1.78)/300)]
ssp = scaled × DynamicTypeScale
sem = scaled
Apple layouts use points, the conceptual equivalent of Android dp. Pixel conversion is explicit and never silently applied to a SwiftUI frame.
let spacing = 16.autoScaledDp
.qualifier(20, .width, minimum: 600)
.qualifier(24, .width, minimum: 900)
.resolve(configuration)let televisionTitle = 24.autoScaledDp
.screen(42, condition: .init(
mode: .television,
orientation: .landscape,
qualifier: .width,
minimum: 1280
))
.resolve(configuration)let title = 20.autoSp
.screen(28, condition: .init(qualifier: .width, minimum: 700))
.resolve(configuration)Conditions are ranked deterministically by specificity, threshold, and declaration order.
let twoDimensional = 24.diagonalDp(configuration)
let balanced = 24.perimeterDp(configuration)let fullyVisible = 320.fitDp(configuration)
let coversViewport = 320.fillDp(configuration)fit takes the smaller width/height ratio. fill takes the larger ratio.
let gap = 16.fluidDp(configuration, options: .init(
qualifier: .width,
fluidViewport: 320...1200,
fluidScale: 0.85...1.4
))The result is clamped below and above the configured viewport range.
let subtle = 16.interpolatedDp(configuration, options: .init(
interpolation: 0.25
))An interpolation of 0 is fixed; 1 is linear; intermediate values damp scaling.
let perceptual = 24.powerDp(configuration, options: .init(power: 0.75))
let damped = 24.logarithmicDp(configuration)These are useful on large iPad, Mac, television, and visionOS windows where linear growth may be excessive.
let halfWidth = 100.percentDp(
configuration,
percent: 50,
qualifier: .width
)With a base value of 100, this returns the literal percentage of the selected axis.
let candidates = DimensResize.steps(
minimum: ResizeBound.fixedSp(12).points(configuration),
maximum: ResizeBound.percent(12, .width).points(configuration),
step: 1
)
let fontSize = DimensResize.largestFitting(candidates) { candidate in
measuredTextWidth(fontSize: candidate) <= availableWidth
}The fitting predicate must transition monotonically from true to false.
let tenMillimeters = DimensUnits.points(
10,
from: .millimeter,
configuration: configuration
)
let oneInchOnKnownHardware = DimensUnits.pixels(
1,
from: .inch,
configuration: configuration,
pixelsPerInch: 460
)UIScreen.scale/displayScale means pixels per point, not physical pixels per inch. Pass PPI only when the application has a reliable hardware mapping.
import Metal
import AppDimensMetal
let uniforms = AppDimensUniforms(configuration)
precondition(MemoryLayout<AppDimensUniforms>.stride == 64)
let buffer = AppDimensMetal.makeBuffer(
device: device,
configuration: configuration
)!Matching Metal Shading Language declaration:
struct AppDimensUniforms {
float4 viewport;
float4 ratios;
float4 display;
float4 reserved;
};Retain the buffer. Call update only after window, display, orientation, or Dynamic Type changes.
| Android | Apple port |
|---|---|
Configuration |
DimensConfiguration |
LocalConfiguration.current |
environment installed by .appDimens() |
16.sdp |
@ScaledDimension(16) or 16.sdp(configuration) |
16.ssp |
@ScaledDimension(text: 16) |
DpQualifier.SMALL_WIDTH |
.smallWidth |
Inverter.SW_TO_LW |
.swToLw |
| Maven principal artifact | AppDimens product |
| Maven satellite artifact | corresponding SwiftPM product |
| BOM/all dependencies | AppDimensDynamic product |
- Install one SwiftUI provider per scene, at the root.
- Never create a new principal configuration from a card or child view.
- In UIKit, pass any descendant only to locate its window; the implementation uses window bounds.
- Use percent/fit/fill when local proportional behavior is explicitly wanted.
- Treat points and pixels as different units.
- Re-resolve after window configuration changes.
- Keep Metal buffer allocation outside the frame loop.
The SwiftUI tree probably lacks .appDimens() at its scene root. Install the provider once above every consumer.
That is expected: each UIWindowScene is an independent Android-Configuration equivalent. Values must remain identical only inside the same window.
Do not replace the principal configuration. Use an explicit percent, fit, or fill calculation for that container-specific requirement.
Do not combine a pre-scaled ssp point size with another UIFontMetrics scaling pass unless double scaling is intentional. Prefer one Dynamic Type path.
Provide verified PPI. Display scale alone cannot determine physical dimensions.
- Documentation hub
- Beginner guide
- Complete API reference
- Strategy catalog and individual guides
- Mathematics and calculus
- SwiftUI and UIKit integration
- Android migration map
- Performance and recomposition
- Troubleshooting
- Complete module matrix
- Principal parity audit
- Codex usage skill
- Contributing guide
- Security policy
swift package dump-package
swift package describe
swift test --parallel
swift test --sanitize=thread
swift build -c releaseThe GitHub workflow additionally builds AppDimensDynamic against iOS, tvOS, visionOS, and macOS SDKs without code signing.