為 iPhone Duo 打造 React Native 理財 App:合上手機如何完成每日 Close 流程
Folding it shut closes the day: building a React Native money app for the iPhone Duo
6pm 為 iPhone Duo 開發 React Native 理財 app,按手機摺疊姿態切換版面,並把合上手機設定為完成當日 Close 流程的操作。只有待處理卡片全部完成後,合上手機才會結束流程;仍有卡片未處理時,流程會在外屏繼續,避免未確認的支出被保留。
Apple's iPhone Duo goes on sale on Friday 23 October. 6pm, our daily money app, already lays itself out for each way you can hold it. We built it in mid-October on the iPhone Duo simulator in Xcode 27.1, and it ships in 6pm 2.0.0 on the App Store on 6 November.
This post covers what the iOS 27.1 SDK gives you, how we got it into React Native, and the one design question that took the most thought: what should folding a phone shut mean in an app you close once a day?
Every snippet is from the open-source repo (github.com/at6pm/6pm, Apache 2.0).
What the platform gives you
Three things in the iOS 27.1 SDK matter:
-
UIHingeInteraction: added to a view, it reportsUIHingeupdates with astatus(closed, partially open, fully open) and ananglein radians. -
UIView.reservedRegions(kind:):.divisionis the fold across the inner screen, and.occlusionis the vertical island on the outer screen. Each comes with a frame in the view's space. -
UIArrangementViewController: split and overlay arrangements for native two-pane apps. 6pm draws its own pages in React Native, so we read the regions instead.
There is no single "pose" value. "Held like a book" and "stood on a table" are both "partially open"; the difference is which way the fold runs across the window. So the pose is yours to work out.
Some readings from the simulator, which helped us write tests:
| Held | Hinge | Window | Division |
|---|---|---|---|
| Closed | closed, angle 0 | 466×678 | none; the island strip at the top right |
| Book | partial, ≈2.23 rad | 951×669 | 40 pt upright, at x 455.5 |
| Flat | open, π | 951×669 | none once it lies flat |
| Stand | partial | 669×951 | across, at y 455.5 |
A small native module, events only when something changes
The Swift side is one Expo module of about 200 lines. It adds an invisible probe view that fills the key window, re-reads the regions whenever the window lays out (folding, turning and Split View all resize it), and holds a UIHingeInteraction:
let interaction = UIHingeInteraction { [weak self] _, update in
guard let self else { return }
if let hinge = update.hinge {
self.hinge = Self.name(hinge.status)
self.angle = Double(hinge.angle)
} else {
self.hinge = "unknown"
self.angle = nil
}
self.report()
}
window.addInteraction(interaction)
report() sends a snapshot to JS only when it differs from the last one, so a scroll that relays the window out sends nothing. The JS thread reads the last snapshot the main thread took, never UIKit itself.
In JS, the module is optional: requireOptionalNativeModule('Fold') returns null in tests and on other platforms, and a provider turns it into React state. The angle also goes into a Reanimated shared value, so motion that follows the hinge stays on the UI thread:
const subscription = fold.addListener('onFold', (next) => {
angle.set(next.angle ?? -1);
setSnapshot(next);
});
Five layouts from one pure function
Everything that decides layout lives in foldForm(snapshot, window), a pure function with Jest tests and no device needed:
export type FoldForm =
| { readonly kind: 'phone' }
| { readonly kind: 'rail'; readonly rail: number }
| {
readonly kind: 'spread';
readonly left: Page;
readonly right: Page;
readonly rail: number;
readonly book: boolean;
}
| { readonly kind: 'stand'; readonly top: Half; readonly bottom: Half };
- rail: closed, and something covers the window's right edge (the island). The status, Add, the tabs and Scan move into a 72 pt rail on the right, and the sky and the day's number take the full height.
-
spread: opened sideways with room for two pages of at least 360 pt. It splits at the division, or at the middle with a 40 pt gutter when the screen lies flat and reports no fold.
bookis the same spread part way open. - stand: part way open with the division running across. The top half is for reading, the flat half for touching.
- phone: everything else, including the Duo opened upright and flat.
One rule from the design governs all of them: nothing you tap sits in the fold. Lists may scroll across it; controls never.
On the spread, the left page is Today, Plan or the Diary. The right page is the month, a spend you tapped, the Diary's chosen day, or Ask 6pm.
The design question: what does folding it shut mean?
6pm's core moment is the Close. At 6pm you go through the day's captured spends as cards (Keep, Edit, Not mine), and the day is stamped closed. On the opened Duo, the Close lays out as a book: the cards on the left page, and on the right a ledger with what's been kept and what's still yours today.
Folding a phone shut is the most natural "I'm done" gesture there is. But in a money app, "done" can't mean "keep whatever I didn't look at". So the rule is:
Folding the phone shut finishes the Close once no card is waiting, exactly as the reveal's Done does. With cards still waiting, it carries on on the outer screen, so nothing is ever kept that was not decided.
In code that's a small hook. It fires only on a transition into closed from part way or fully open, so launching the app on the outer screen never closes anything:
export function useFoldShut(onShut: () => void, enabled: boolean) {
const { snapshot } = useFold();
const latest = useRef(onShut);
useEffect(() => {
latest.current = onShut;
});
const was = useRef(snapshot.hinge);
useEffect(() => {
const before = was.current;
was.current = snapshot.hinge;
if (enabled && snapshot.hinge === 'closed' && (before === 'partial' || before === 'open')) {
latest.current();
}
}, [snapshot.hinge, enabled]);
}
The Close screen enables it only after the card step, and the hinge and the Done button share one useFinishClose. It runs once, works the day's number out for the closed day itself, and lets a failed save be tried again. The outer screen then shows tomorrow's number.
Standing it on a counter: Market mode
The stand pose turned out to be the most "real life" one. Half open with the fold across, on Home, 6pm becomes a counter. The top half shows what's left for today in numbers readable from a step away. The flat half has a keypad, the hour's likely categories and Add. Each Add is saved at once as a kept cash spend, the number goes down, and the spend shows as a chip. Think of a morning at the market with cash in hand.
The same pose powers Scan to pay: the inner camera and a frame to hold the stall's QR up to on the top half, the confirm and the bank hand-off on the flat half. While a payment is under way, the top half shows the amount and payee large enough for the seller to read.
Gotchas
Check the SDK, not the compiler. We first guarded the 27.1 calls with #if compiler(>=6.4). Then Xcode 27.0 shipped the same Swift 6.4 with the iOS 27.0 SDK, and the build broke there. UIKit's module version follows the SDK, so that's what we check now:
#if canImport(UIKit, _version: 9127.0.85) // iOS 27.1's UIKit
if #available(iOS 27.1, *) {
// UIHingeInteraction, reservedRegions(kind:)
}
#endif
#available still guards the runtime, for people on older iOS.
An older SDK means no hinge, silently. A build from an older Xcode runs fine on the Duo, with one page everywhere. Our CI built signed apps with Xcode 26.6, so we moved signed builds to the Xcode 27 runner image, picking 27.1 once it's a release build. Never a beta: App Store Connect refuses uploads built with one.
The inner screen turns on its own. iOS ignores the portrait lock there, so every inner-screen layout has to work both ways round.
The simulator folds from Device Hub, not simctl. simctl has no posture command. We drove the hinge from scripts with agent-device fold closed|half-open|open, which sends the hinge event inside the simulator and reads the angle back.
Every pose change animates
Pages slide apart and the rail eases in; nothing jumps. The order of things is the same on both screens, so shutting the phone part way through keeps your place.
6pm 2.0.0 is on the App Store from Friday 6 November, 18:00 ICT. Until then the beta is on TestFlight (join here), and the code is at github.com/at6pm/6pm.
Written with AI assistance from our own code, commits and design docs; every snippet is from the repo.
來源:DEV Community · dev.to