Skip to content

Latest commit

 

History

51 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SurfaceForge

SwiftUI + Metal surfaces that react to device tilt.

Swift 6 iOS 17+ No dependencies MIT

A gold surface catching the light as the phone tilts

The surface above, in full:

import SurfaceForge

Surface(material: .gold, cornerRadius: 22, contentPadding: 20) {
    VStack(alignment: .leading, spacing: 2) {
        Text("MEMBER SINCE")
            .font(.system(size: 10, weight: .medium))
            .foregroundStyle(.tertiary)
        Text("2019")
            .font(.system(size: 15, weight: .medium))
            .foregroundStyle(.secondary)

        Spacer()

        Text("GOLD")
            .font(.system(size: 11, weight: .medium))
            .foregroundStyle(.tertiary)
        Text("4471 0982 3310")
            .font(.system(size: 26, weight: .semibold))
            .monospacedDigit()
    }
}
.frame(width: 353, height: 220)

Every line after Surface is plain SwiftUI. Size, placement, layout and spacing are yours; the package only owns the surface and its light.

Parameter Default
material .gold What the surface is made of.
cornerRadius 22 Continuous, squircle corners.
contentPadding 20 Inset between your content and the surface's edge.

Your content fills the surface's bounds first, and contentPadding insets it after, so Spacer() and alignments reach the edges as you would expect.

Install

Xcode. File → Add Package Dependencies, then paste:

https://github.com/AetherMaker/surface-forge

Package.swift.

.package(url: "https://github.com/AetherMaker/surface-forge", from: "0.4.0")

Materials

.gold  .silver  .roseGold  .copper  .brass  .gunmetal

SurfaceMaterial.all          // every built-in, in order. For a picker.
material.name                // "Rose gold"
material.approximateColor    // a flat Color for a swatch, not what the surface looks like

SurfaceMaterial.custom(tint: .init(red: 0.8, green: 0.9, blue: 1), name: "Platinum")

A custom tint should be richer and more saturated than the metal you want. The shader multiplies it by your content's own luminance, which both lifts and desaturates it. Gold is stored as (1.00, 0.72, 0.22) and renders near (214, 187, 114).

Light

.surfaceLightOffset(0)     // centred. -1 is the left edge, +1 the right
.surfaceGleam(1)           // 0 is flat matte, 1 is the full reflection

Both move all of a surface's lighting together, so the shading and the highlight never disagree about which side the light is on.

They propagate through the environment, and that is deliberate: two surfaces on screen are in the same room, so one lit from the left beside one lit from the right is a bug. Set either once, high in a tree.

Tilt

.surfaceTiltSource(.deviceMotion)                 // follows the device
.surfaceTiltSource(.fixed(pitch: -15, roll: 20))  // a held angle

Previews and the Simulator have no motion data, so .deviceMotion holds the resting reflection there. .fixed is how you see the material move without hardware, and how you get a repeatable screenshot or test.

Every surface on screen reads the device once, through one shared source that starts with the first and stops with the last. A ScrollView of them costs what one costs.

Content is read as luminance

Every material here is metallic, and metal keeps the lightness of your content and discards its colour. A blue element appears as the material's own colour at blue's brightness.

Use .primary, .secondary and .tertiary. They keep their relative hierarchy through the material, so there is no colour API to learn.

Full-colour content is not supported yet. If you need a coloured logo on the surface, this cannot do it today.

Why a shader, and not a gradient

A moving gradient gets most of the way there and breaks in four places.

A gradient's band is always the same shape. Here the highlight is a specular lobe over a normal that varies per pixel across a cylindrical bow and three plane waves at irrational angles, so the band compresses and shimmers as it crosses.

A view cannot read what is behind it. The material multiplies your content's own luminance, so a light surface turns metal while dark text stays dark. Legibility falls out of the model instead of being hand-authored twice.

The gleam crosses your text. Behind the content it is occluded and reads as printed metallic art; in front it erases the text. Here it passes over and is damped by how dark each pixel is.

No SwiftUI primitive has a view vector, so a gradient's edges behave exactly like its middle. Fresnel needs one.

Demo app

Example/SurfaceForgeDemo.xcodeproj is one surface with a control for every knob. It runs on a Simulator with no Apple account and no signing setup.

Finish

The same metal, worked differently:

Surface(material: .gold.finish(.brushed)) {  }

Twelve finishes ship: polished (the default), brushed, pinstripe, carbon twill, topographic, basketweave, clous de Paris, knurling, sandblasted, sunburst, molten and hammered. Brushed and pinstripe take a direction:

.gold.finish(.brushed(angle: .degrees(90)))

SurfaceFinish.all lists them for pickers, hasDirection says whether an angle means anything, and aimed(at:) applies one where it does. A finish never changes the metal itself: same tint, and text on the surface stays legible under every pattern. Two retune the highlight the way the real work would: sandblasted scatters it soft and wide, and molten, which turns the surface itself to liquid, folds it into tongues of light that swirl as the device tilts.

Patterned cards

PatternedSurface is a separate card system for patterned identity cards. It does not turn the entire card into metal. The card itself stays still by default; only its pattern, broad background glow, and an explicitly selected hero respond to tilt. Text and normal content stay sharp above the artwork.

let mark = PatternElement.systemSymbol("heart", weight: .medium)

PatternedSurface(
    background: .identityPrismatic,
    pattern: .identitySpiral(element: mark),
    sheen: .identity
) {
    HStack {
        VStack(alignment: .leading) {
            Text("ANDREAS").font(.headline.weight(.bold))
            Text("Verified since February 2019")
            Spacer()
            Text("Trust is the cornerstone of our community.")
        }
        Spacer()
        Image("profile")
            .resizable()
            .scaledToFill()
            .frame(width: 88, height: 112)
            .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
            .surfaceHologram(.identityFoil)
    }
}
.frame(width: 353, height: 220)

Background colour can be as simple or as directed as the caller needs. A solid colour is preserved exactly; automatic mode derives a perceptual, gamut-safe three-stop colourway from one base colour; custom mode accepts SwiftUI's native Gradient, including any number of stops.

.color(.indigo)

.automatic(
    base: .indigo,
    flow: .coolToWarm,
    style: .linear(angle: .degrees(-45))
)

.linearGradient(
    Gradient(colors: [.purple, .pink, .orange]),
    angle: .degrees(-45)
)

Automatic flows can be .coolToWarm, .warmToCool, or .neutral. Both automatic and custom gradients support linear and radial geometry. Pattern ink and sheen can remain relative to the rendered background or be overridden with fixed colours independently.

For a card whose background can change at runtime, automatic pattern ink solves one related colour against every gradient stop and the sheen's brightest state. The result behaves like one printed pigment instead of switching between light and dark ink across the card. A fixed brand colour and the old per-pixel adaptation remain explicit alternatives.

Use .dark for restrained printed linework, .light for pale foil-like artwork, or .bestContrast when legibility should decide automatically.

color: .automatic(
    preference: .dark,
    minimumContrast: 1.8,
    saturationShift: -0.06
)

color: .fixed(brandInk)

color: .locallyAdaptive(minimumContrast: 3.2)

The pattern element can be a SwiftUI Shape, Path, SF Symbol, direct SVG, CGImage, or a loose image file in a Bundle. Its id is yours: change it whenever supplied artwork changes, so the cached mask is invalidated correctly. Grid, staggered, radial, and arc-length-spaced spiral layouts expose their spacing, centre, phase, rotation, and orientation directly. A spiral's radialSpacing is the gap between neighbouring strands whatever arms is set to, and startRadius keeps the focus bare, where the arms crowd together.

let brandMark = try PatternElement.svg(
    """
    <svg viewBox="0 0 24 24">
      <path d="M12 2 L22 12 L12 22 L2 12 Z"
            fill="none" stroke="black" stroke-width="1.8"/>
    </svg>
    """,
    id: "brand-mark-v1"
)

// Or keep the SVG as an app-bundle resource.
let bundledMark = try PatternElement.svgResource(
    named: "brand-mark.svg",
    bundle: .main,
    id: "brand-mark-v1"
)

SVG is parsed once into the same cached vector mask as a Path. The importer supports paths including arcs, the basic geometric elements, fills, strokes, groups, and transforms. It rejects gradients, masks, text, images, <use>, and dashed strokes rather than silently rendering the wrong mark.

The pattern reveal is its own moving, soft light. It does not make a hard visible/invisible split across the card. Its public controls are explicit:

reveal: .init(
    restingVisibility: 0.01,              // almost hidden at rest
    tiltResponse: 0.94,                   // activates under a small wrist tilt
    anchor: .center,                      // initial light position
    travel: 0.30,                         // maximum movement toward the raised edge
    spread: CGSize(width: 0.42, height: 0.42),
    falloff: 2.4                          // larger = a quieter outer field
)
PatternedSurfaceMotion.artworkOnly                 // default: card outline stays still
PatternedSurfaceMotion.cardTilt(maximumAngle: .degrees(5))

// Exact, repeatable preview or visual-test state. This suppresses live motion.
.patternedSurfaceTilt(.init(x: 0.62, y: 0.34))

The hero treatment is false-colour foil, not a rainbow hologram. With a portrait matte, dark source detail merges into the field around it, violet when the hero is cool and champagne when it is warm, so only light skin detail carries the face.

The light sits in one place, as it would for a card held under a lamp. The band always travels along the same axis: tilting toward the light carries it across the hero, tilting across that axis slides nothing, and tilting away leaves it parked off the edge, where it rests.

SurfaceHologram(palette: .identity, light: .fixed(.degrees(156)))
SurfaceHologram(palette: .identity, light: .followsGesture)

The angle names where the band travels to, entering from the opposite edge. carries it to the right, 90° to the top, and the default up and to the left.

.followsGesture is the loose alternative: the band arrives from whichever way the card is tilted. Nothing physical behaves that way, but it answers every gesture, which suits a card that has to look alive at a glance.

Soft shoulders and a low-frequency warp stagger when nearby pixels change, so the cool state returns behind the band instead of leaving a hard stripe.

SurfaceFoilPalette sets the colourway. SurfaceHologram(arrivalDuration:) sets roughly how long the band takes to settle, and sweepDuration caps how fast a full pass can run. Small wrist movement is deliberately quiet: the default finishes a pass only near a full gesture. A held card is completely still unless idleMotion is above zero. Omit .surfaceHologram and the hero keeps its original pixels.

Matting a hero

The matte tells the foil where the subject ends. Draw one by hand, or let Vision find the subject:

let mask = try await SurfaceHologram.subjectMask(for: photo)

Run it once, when the asset is added or uploaded, and store what it returns. It costs a machine-learning pass, so calling it while a card is on screen stalls the first frames and costs the same again every cold launch. On a 576x768 portrait it took 590 ms and landed within a pixel of a hand-drawn matte.

It throws noSubjectFound when the image holds nothing it recognises. Fall back to .surfaceHologram(_:), which needs no matte at all.

Requirements

iOS 17 and macOS 14. No dependencies. No bundled assets.

Motion comes from the device on iOS. On macOS a surface follows the pointer instead, through surfacePointerTilt().

Licence

MIT

About

SwiftUI + Metal surfaces that react to device tilt. Swift package, MIT, no dependencies.

Topics

Resources

Stars

37 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages