- Published on
- 6 min read Intermediate
> Reading the iPhone Duo Hinge Angle with onHingeChange in SwiftUI
Every new piece of iPhone hardware eventually ends up as an input someone builds a toy with, and the hinge on iPhone Duo is no exception. iOS 27.1 exposes it directly. SwiftUI has an onHingeChange modifier and UIKit has UIHingeInteraction, and both report how far the device is open along with a simple closed, partially open, or fully open status.
Before writing any code, it's worth being clear about what this API is for, because the obvious first idea is usually the wrong one.
Hinge Data Versus Layout
It's tempting to watch the hinge and swap layouts when the device folds. Don't. Apple's Leverage multiple displays and scenes on iPhone Duo tech talk draws a clear line between hinge data and the layout APIs, and layout belongs to the layout APIs.
The reason is that folding is only one of several things that change your app's shape. Rotation, Split View multitasking, and moving between the inner and outer displays all do too, and size classes, reserved regions, and ArrangementView already account for all of them. A layout keyed off the hinge angle would miss most of those cases and fight the system on the rest.
What the hinge is good for is interaction. It's a physical control that people move with their hands, so it works well for effects where the motion itself matters: scrubbing through something, adjusting a value, or animating an illustration that mirrors the device.
Observing the Hinge in SwiftUI
onHingeChange(isEnabled:_:) calls your closure with the old and new DeviceHingeContext. The context's hinge property is optional because most devices don't have one, so nil is the normal case on anything that isn't an iPhone Duo.
When there is a hinge, DeviceHinge gives you two values. angle is a SwiftUI Angle, and status is one of .closed, .partiallyOpen, or .fullyOpen. The simplest use is just tracking the status:
struct HingeBadge: View {
@State private var status: DeviceHinge.Status?
var body: some View {
Label(title, systemImage: "rectangle.split.2x1")
.onHingeChange { _, newContext in
status = newContext.hinge?.status
}
}
private var title: String {
switch status {
case .partiallyOpen?: "Partially open"
case .fullyOpen?: "Fully open"
case .closed?: "Closed"
default: "No hinge"
}
}
}
The isEnabled parameter defaults to true. Passing false stops the updates without removing the modifier, which is handy when the effect only makes sense in one mode of your screen.
A Fold-to-Scrub Timeline
Here's a more interesting example: a time-lapse viewer where partially folding the device scrubs through the frames. Open it a little further and the time-lapse moves forward, close it slightly and it moves back.
Apple doesn't document the range of angles the hardware reports, and the rate and precision of updates are system policy, so I avoid mapping absolute angles to frames. Instead, the viewer remembers the angle at the moment the device becomes partially open and scrubs relative to that starting point:
struct TimelapseViewer: View {
let frameNames: [String]
@State private var frameIndex = 0
@State private var anchor: ScrubAnchor?
@State private var foldToScrub = true
var body: some View {
VStack {
Image(frameNames[frameIndex])
.resizable()
.scaledToFit()
Slider(
value: Binding(
get: { Double(frameIndex) },
set: { frameIndex = Int($0.rounded()) }
),
in: 0...Double(max(frameNames.count - 1, 1))
)
Toggle("Fold to scrub", isOn: $foldToScrub)
}
.padding()
.onHingeChange(isEnabled: foldToScrub) { oldContext, newContext in
guard let hinge = newContext.hinge, hinge.status == .partiallyOpen else {
anchor = nil
return
}
if oldContext.hinge?.status != .partiallyOpen || anchor == nil {
anchor = ScrubAnchor(angle: hinge.angle, frameIndex: frameIndex)
}
guard let anchor else { return }
let degreesMoved = hinge.angle.degrees - anchor.angle.degrees
let offset = Int((degreesMoved / 2).rounded())
frameIndex = min(max(anchor.frameIndex + offset, 0), frameNames.count - 1)
}
}
}
struct ScrubAnchor {
let angle: Angle
let frameIndex: Int
}
The old context earns its place here. Comparing it with the new one tells you the device has just entered the partially open state, which is exactly when the anchor should be set. From then on, every two degrees of movement is one frame. When the device closes or opens fully, the anchor clears and the frame stays where it was.
Notice the slider, too. The hinge is a bonus input. On every device without one, and for anyone who doesn't want to fold their phone to use your app, the slider does the same job. If a feature only works through the hinge, most of your users will never find it.
Being Careful With Angle Updates
A few habits keep hinge-driven effects feeling good. Treat updates as samples rather than a continuous stream. The UIKit documentation says outright that you shouldn't depend on a particular update frequency or precision, so anything that needs to look smooth should animate between values rather than assume a new one arrives every frame.
If you only need to know whether the device is closed, partly open, or fully open, use the status instead of the angle. The status is the stable, meaningful signal, and it's what Apple recommends when that's all you need.
Finally, keep the effect reversible and forgiving. People fold their phones for all sorts of reasons, most of which have nothing to do with your app, so a hinge gesture should never do anything destructive or hard to undo.
UIHingeInteraction in UIKit
UIKit exposes the same data through an interaction. Create a UIHingeInteraction with an update handler and add it to a view. The handler runs once with the initial state, then again whenever the hinge changes or the interaction moves to a different view hierarchy:
final class TimelapseViewController: UIViewController {
private let angleLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(angleLabel)
let hingeInteraction = UIHingeInteraction { [weak self] _, update in
guard let self else { return }
guard let hinge = update.hinge, hinge.status == .partiallyOpen else {
angleLabel.text = nil
return
}
let degrees = Int((hinge.angle * 180 / .pi).rounded())
angleLabel.text = "Scrubbing at \(degrees)°"
}
view.addInteraction(hingeInteraction)
}
}
There are two differences from SwiftUI worth knowing. UIHinge reports its angle as a CGFloat in radians, and its status enum has an extra .unknown case. The update's hinge is nil when the interaction leaves a hierarchy that provides hinge updates. And because the handler is stored, capture self weakly like the example does, or you'll create a retain cycle.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
Wrapping Up
The hinge APIs are small and easy to use, which makes it easy to use them for the wrong job. Leave layout to size classes, reserved regions, and arrangement views, and save onHingeChange and UIHingeInteraction for effects that respond to the physical act of folding. Scrub relative to where the fold started instead of assuming specific angles, prefer the status when it's enough, and always give people another way to do the same thing.
// Continue_Learning
How to Get Your App Ready for iPhone Duo
iPhone Duo ships on October 23 with a folding inner display, a compact outer display, and toolbars that move to the side. Here's what to check in your app first, and where the new iOS 27.1 APIs fit in.
Building a Teleprompter for iPhone Duo's Outer Display with CameraCaptureAccessory
When iPhone Duo is open, its outer display faces the same way as the rear camera. CameraCaptureAccessory lets your app put content there while it records. Here's how to build a scrolling teleprompter with it in SwiftUI and UIKit.
Keeping Content Clear of the iPhone Duo Fold with ReservedRegion
iPhone Duo reserves parts of its displays for the fold and the front cameras. Learn which regions exist, when system views handle them for you, and how to query ReservedRegion in SwiftUI and UIKit to move custom content out of the way.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.