Reading the hinge on iPhone Duo, and designing around what Apple won't promise

iOS 27.1 gives every app the iPhone Duo's hinge status and a live angle, with no entitlement. The API is small. The interesting part is the one promise Apple leaves out, and how to design an interaction that doesn't need it.

The hinge was the first thing I looked for in the iOS 27.1 SDK, because it’s the one part of iPhone Duo no other iPhone has. I half expected it to be private, or behind an entitlement, or only exposed as a layout change.

It’s none of those. Any app can read it, in SwiftUI or UIKit, and the headers are short enough to read in one sitting.

SwiftUI

The modifier is onHingeChange. It hands you the old and the new context, and the context holds an optional hinge:

struct PageView: View {
    @State private var isPartlyOpen = false

    var body: some View {
        Content(isPartlyOpen: isPartlyOpen)
            .onHingeChange { _, new in
                guard let hinge = new.hinge else {
                    // No hinge on this device.
                    isPartlyOpen = false
                    return
                }
                isPartlyOpen = hinge.status == .partiallyOpen
            }
    }
}

DeviceHinge has a status and an angle, and the angle is a SwiftUI Angle. A nil hinge means the device doesn’t have one, which is what every other iPhone reports.

One small surprise: DeviceHinge.Status is a struct with static members, not an enum. You compare against .closed, .partiallyOpen and .fullyOpen, but a switch over it needs a default case.

UIKit

UIKit uses an interaction, the same pattern as pointer and drag interactions:

let interaction = UIHingeInteraction { [weak self] _, update in
    guard let self else { return }
    guard let hinge = update.hinge else {
        self.hingeBecameUnavailable()
        return
    }
    self.apply(status: hinge.status, angle: hinge.angle)
}
view.addInteraction(interaction)

The handler fires once with the current state and again on each update. It also fires when the view moves between hierarchies, and the hinge is nil when the view has left a hierarchy that provides hinge updates. The UIKit status has an extra .unknown case the SwiftUI one doesn’t.

Two details that bit me on first read. The UIKit angle is a CGFloat in radians, while the SwiftUI angle is an Angle, so shared code needs to pick one. And a disabled interaction doesn’t queue updates: turn it back on and you get the current state once, not the history you missed.

There’s nothing in Core Motion, Game Controller or Metal. If a game wants the hinge, it reads it through UIKit or SwiftUI like everyone else.

The promise Apple leaves out

This is the line in the header I keep coming back to. On UIKit’s UIHinge.angle, Apple writes that the rate and granularity of updates are system policy and can change with system state, so you shouldn’t depend on a particular update frequency or precision. The SwiftUI documentation doesn’t repeat it, but I’d assume the same policy applies. If you only need to know whether the hinge is closed, partly open or fully open, use status.

That’s an unusually direct warning. It means an interaction that needs a smooth, fine-grained stream of angles might work on one day and feel rough on another, depending on things the app can’t see. I can’t test how rough in the simulator, because there I’m driving the hinge myself, so it can’t tell me what update rate real hardware delivers.

So I’ve started designing hinge interactions in three tiers.

Status first. Anything that can be expressed as closed, partly open or fully open should be. The system decides the status from the angle and the device’s orientation, so you inherit Apple’s judgement about where the boundaries are.

One half of the device lies flat while the other rotates from closed to fully open. Near closed the status is closed, across the middle it is partially open, and at the end it is fully open. The continuous angle is measured between the halves. The system decides the boundaries.
Status is coarse and dependable. The angle underneath it is continuous, and its update rate is up to the system.

Thresholds second. Some interactions need a boundary Apple doesn’t define, like “this half has been lifted far enough”. Those use two thresholds rather than one, so a hinge hovering near the boundary doesn’t flicker:

struct LiftDetector {
    var showBelow: Angle
    var hideAbove: Angle
    private(set) var isLifted = false

    mutating func update(with hinge: DeviceHinge) -> Bool {
        if !isLifted, hinge.angle < showBelow { isLifted = true }
        if isLifted, hinge.angle > hideAbove { isLifted = false }
        return isLifted
    }
}

A threshold only needs the angle to cross a line eventually. It doesn’t care how many updates arrived on the way.

As the hinge angle falls past the show threshold the content appears, and it only hides again once the angle rises back past the higher hide threshold, so small wobbles near either line change nothing.
Two thresholds: content appears once the angle falls past one line, and hides only after it rises back past the other.

Continuous last. Interactions that follow the angle directly, like a bellows or a lever, are the most fun and the most exposed to that warning. I’m building them, but with smoothing between updates, and with the expectation that they’ll need tuning the day I hold real hardware.

Don’t lay out with it

The other thing Apple is clear about is that the hinge isn’t a layout tool. When the device is partly open, the fold becomes a reserved region of the view, and that’s what layout should read. ComicFlow’s reader finds the fold that way and never looks at the angle at all.

It took me a while to see why that split matters. Layout that follows the angle reflows every time someone adjusts their grip. Layout that follows the fold region changes once, when the fold appears, and then leaves the content alone.

The hinge ended up being less of a sensor than I imagined and more of a switch with a dial attached. The switch is dependable. The dial is a bonus, and I’m designing as if it might not always be there.