Return...
5つの画面にまたがる禅瞑想・集中タイマー:iPhone、iPad、Apple Watch、Apple TV、Mac。
2026年4月21日リリース。一つのコードベース。アラビア語とヘブライ語を含む27言語。4つのテーマ、3種類の鐘、ゼロ・アナリティクス。以下は、それがどのようにまとまっていったかの記録です。技術的な選択、デザインのトレードオフ、そしてAIが生成した何百もの水滴を一つに編集していく長く静かなプロセス。
一つのコードベース、5つの画面。
Returnは私が初めて、Appleの全画面クラスにまたがって単一のXcodeプロジェクトから動くようにリリースしたアプリです:iPhone、iPad、Apple Watch、Apple TV、Mac。57本のSwiftファイル、約12,700行のコード、外部依存はゼロ。純粋なSwiftUI、AVFoundation、HealthKit、ActivityKit、WidgetKit。
素朴なやり方は、プラットフォームの違いごとに#if分岐を持つ単一のユニバーサルなTimerManagerを書くことです。私はそうしませんでした。Returnは状態のセマンティクスを共有しつつ、各プラットフォームが本当に得意なことを尊重する3つのタイマークラス(iOSとmacOSではTimerManager、tvOSではTVTimerManager、watchOSではWatchTimerManager)を出荷しています。Live ActivityはiOSのみ。HealthKitはAPIが存在する場所のみ。Extended runtime sessionはWatchのみ。各マネージャーは、単一のポリモーフィックなクラスよりも短く、より正直です。
重要な部分は共有する。
単一のShared/フォルダが、すべてのターゲットで合意が必要な部分を担っています:MeditationSessionデータモデル、SessionStore iCloudラッパー、そしてSessionHistoryView。設定はApp Group(group.com.941apps.Return)を通じてWatchとiPhoneの間で同期されます。それ以外は意図的にプラットフォーム固有です。
最も明快な例は、セッションがすでにHealthKitに記録されたかどうかを判断する一行です。iPhoneは直接書き込むので、セッション終了の瞬間に「同期済み」がtrueになります。MacとTVはHealthKitにまったく書き込めないので、後でiPhoneが保留中のセッションを拾うまで「同期済み」はfalseです。同じ意図、反対のbool値、一つの#if:
/// Save session to SessionStore for cross-device sync and HealthKit syncing private func saveSessionToStore(startTime: Date, endTime: Date) { // On iOS: if healthKitEnabled, we save directly to HealthKit, so mark as synced // On Mac: if healthKitEnabled, we want to sync to iPhone, so mark as NOT synced #if os(iOS) let alreadySynced = settings.healthKitEnabled #else let alreadySynced = !settings.healthKitEnabled #endif let session = MeditationSession( startDate: startTime, endDate: endTime, sourceDevice: .current, syncedToHealthKit: alreadySynced ) SessionStore.shared.addSession(session) }
私はこのパターンに何度も立ち返ります:意図を読み取り可能にしつつ最小の行数で書く。同じbool値が異なるプラットフォームで異なる意味を持つときは、異なるbool値として書く。#ifはドキュメントの一部になります。
27言語、そして右から左へのサポート。
Returnは、私が気にかけているすべての言語でリリースした初めてのAppleアプリです。アラビア語とヘブライ語を含む27ロケールが完全なレビューパスを通過しました。それらすべてが一つのLocalizable.xcstringsファイルに収まっており、聞こえるほど英雄的なものではありません。手作業で文字列を作るのをやめることに同意すれば、Xcodeがほとんどの仕事をしてくれます。





RTLは、抗うのをやめれば無料の勝利。
SwiftUIは.leadingと.trailingを、固定された.leftと.rightではなくセマンティックな方向として扱います。画面をセマンティックな方向で一度レイアウトすれば、専用のコードパスなしで同じ画面がアラビア語、ヘブライ語、ペルシア語、ウルドゥー語で自動的にミラー表示されます。設定ラベルが反転し、戻るシェブロンが反転し、スイッチの位置が逆になる。テーマアイコン(雫、炎、葉)はそのまま。私はこの動作のためにRTLコードを一行も書きませんでした。
リリース時に気づいた例外が一つあります:SwiftUIはレイアウト方向をTextビューにも適用します。これによりアラビア語とヘブライ語のスクリーンショットの最初のカットでは、タイマーが「20:00」ではなく「00:02」と表示されていました — ラテン数字が右から左にレイアウトされていたのです。時刻や数値コンテンツを保持するすべてのTextビューに.environment(\.layoutDirection, .leftToRight)修飾子を一つ付けることで修正できます。上のスクリーンショットは、その修飾子が適用されたリリースのものです。
スクリーンショットセットは、異なる-AppleLanguages引数で同じUIテストを実行するfastlaneによって生成されました。アプリ自身のeffectiveLocaleパターンがフラグを読み取り、ビュー階層を再構築し、結果をキャプチャします。一つのヘルパー、27のロケール、4つのデバイスクラス、すべて一晩で実行できます。
/// The locale to use for the app - either user-selected or system default /// In snapshot mode, always use system language (set by -AppleLanguages) /// to allow screenshot generation for different locales private var effectiveLocale: Locale { if isSnapshotMode || appLanguage.isEmpty { if let preferredLanguage = Locale.preferredLanguages.first { return Locale(identifier: preferredLanguage) } return .current } return Locale(identifier: appLanguage) } var body: some Scene { WindowGroup { WatchContentView() .preferredColorScheme(.dark) .environment(\.locale, effectiveLocale) .id(appLanguage) // Force rebuild when locale changes } }
.id(appLanguage)は、その価値を稼ぐ細部です。これがなければ、SwiftUIは古いビュー階層をキャッシュし、実行時に言語を切り替えても文字列が更新されません。これがあれば、ツリー全体が破棄されて再構築され、すべてが自動的にローカライズされた文字列を再読み込みします。一行で、一カテゴリのバグが消える。
ついにマインドフルネス時間を。
AppleのネイティブWatch Mindfulnessアプリは、組み込みのReflectとBreatheセッションを5分に制限しています。HealthKit API自体にはそのような制限はありません。終了日時が開始日時より後であれば、どんなHKCategorySampleでも喜んで受け入れます。制限はUIにあって、システムにはない。Returnはすべてのデバイスに5〜60分のピッカーを置き、実際に座った時間を書き込みます。
/// Save a mindful session with the given start and end time func saveMindfulSession(start: Date, end: Date) async -> Bool { guard isAvailable else { return false } // Don't save if end is before or equal to start guard end > start else { return false } let sample = HKCategorySample( type: mindfulType, value: HKCategoryValue.notApplicable.rawValue, start: start, end: end ) ... }
唯一の検証はend > startです。これがHealthKit自身が検証するすべてです。AppleのAPIは常に45分の瞑想を記録する用意がありました。それを要求するボタンが欠けていただけです。
3つのデバイスにHealthKitがなくても、デバイス間で。
MacとApple TVにはHealthKitがまったくありません。当たり前の反応は「だったらそこではセッションを記録しなくていい」です。当たり前ではない、正しい反応は、それでも記録する、iCloud Key-Value Storeに、そして次に起動したときにiPhoneに拾わせる、というものです。ReturnのSessionStoreが共有ストアで、MeditationSession.syncedToHealthKitが保留フラグ、そしてHealthKitManager.syncPendingSessions()がiOSアプリがフォアグラウンドに戻るたびに実行されます。
iCloud Key-Value Store
これはApple自身が出荷すべきだと私が思う部分です:Macで瞑想したいときにiPhoneがアクティブである必要のない、適切なクロスプラットフォームのマインドフルネス時間ライター。彼らがそうするまで、Returnがそれをやります。
水はどこから来たのか。
4つのテーマ。4つの環境ループ。3種類の鐘。すべて生成されたもので、ほとんどは捨てられました。動画はMidjourney、音声はElevenLabs、そして重要だった作業はプロンプティングではありませんでした。編集こそが大事だったのです。200の水滴のグリッドを見て、目に見える継ぎ目なく綺麗にループするものを選ぶ。40種類の寺の鐘を聞いて、適切なアタックと減衰を持ち、電話の通知音のように聞こえないものを一つ選ぶ。




すべてのタイルが一つの生成です。ハートは最初のパスを生き残ったもの。再生三角マークは動画化したもの。4つのテーマがリリースされました。それ以外はすべてグリッドの中に残り、それがプロセス全体の要点です:比率が重要なのです。
鐘も音声で同じ軌跡を辿りました。プロンプト、聞く、調整、また プロンプト。3つを残しました:Singing Bowl、Temple Bell、Soft Chime。それぞれ、合成音らしさが消えるまで反復しました。
総生成数を数えるふりはしません。テーマごとに何百回というのが正直なところです。規律はプロンプトにあるのではありません。単に「良い」だけのものをすべて捨て、20分の静かなタイマーの背後に座っていても気を引くことがないものだけを残すことにあります。
なぜ先生ではなく、タイマーなのか。
ここからは個人的な話です。私がReturnを作ったのは、すでに瞑想の実践をしていて、邪魔にならないタイマーが見つからなかったからです。私が向き合っているのは、武家の流れにある日本の禅です:沢庵、柳生、武蔵、道元、白隠。大手アプリが提供している治療的なマインドフルネスではありません。意図も、肌触りも違います。
ある一週間で巡るもの:
- Susokukan(数息観)。呼吸に合わせて1から10まで数え、数を見失うたびに1に戻る。基礎。集中、すなわちjoriki(定力)が最初。
- Shikantaza(只管打坐)。対象なし。数えず、問わず、観想せず。固着しない心。道元の中心的な坐禅の形であり、私が実際に望む状態に最も近い形式的な近似。
- Koan(公案)。主に趙州のMu。考えることでは解けない問いを、考えることが諦めるまで保持する。
- Maranasati(死念)。Hagakure(葉隠)の枠組み。控えめに使う。生存は心を引き締める;これはそれを切り抜ける。
- Isshin(一心)。沢庵と柳生の領域:弛緩しつつ献身的、定まりつつ動く。座蒲と次に来るものとの間の橋。
- 統合の日。感謝、慈悲、伝統。Jihi(慈悲)。Katsujinken(活人剣):人を殺す剣ではなく、人を生かす剣。たいてい土曜日。
- Sakki(殺気)。すべてのセッションの後に、5分間の開かれた場の聴取を加える。只管打坐を座蒲から下ろし、日常の環境で圧力テストにかける。
巡る順序は厳格ではありません。安定が必要なときは数息観。突破が必要なときは公案。開かれた中で休むときは只管打坐。賭け金を明確にする必要があるときは死念。多様性は修行の一部です。
Returnがタイマーであるのは、私がスマートフォンの中に先生を必要としていないからです。私が必要としているのは、私が時計を持たなくて済むようにそれを持ってくれる何か、私が尊重できる鐘で始まりと終わりを告げる何か、そしてその間は邪魔にならない何かです。すでに実践があるなら、おそらくあなたもそれが欲しいでしょう。まったく初心者であれば、部屋の中で先生を見つけてください。それから戻ってきてください。
Returnに無いもの。
ReturnはCalmではありません。Headspaceでもありません。ボディスキャンへといざなう英国人ナレーターはいません。連続記録を祝うキャラクターアバターもいません。新しいガイド付きプログラムのロックを解除するサブスクリプションもありません。Returnはタイマーです。考え方は、すでに実践があるなら、アプリの中に先生は必要ない、ということです。あなたに必要なのは、時間を保ってくれて邪魔をしないツールです。
- ガイド音声やナレーション無し
- 連続記録、スコア、ゲーミフィケーション無し
- サブスクリプションもアプリ内課金も無し
- 広告は永久に無し
- アナリティクス無し;アプリは何も追跡しない
- ソーシャルログインや共有機能無し
- うるさい画面、コールドスタートのモーダル無し
- アプリ内課金フローのダークパターン無し、なぜならアプリ内課金フローが無いから
Returnにあるものは、意図的に小さく保たれています:4つの繰り返しモード(一回、停止するまで、時間まで、N回繰り返す)、サイクル間の2秒の呼吸の間、各遷移での1〜3回の鐘、3種類の鐘の選択、4つのテーマ、HealthKitオプトイン、そして言語ピッカー。それが製品の全てです。
ここまで厳格であるコストは、設定モデルに表れます。すべてのユーザー向け設定は、UIバリデーションではなく、プロパティ自身によって有効な範囲にクランプされます。注意しないと、UIバリデーションもまた一つのダークパターンです。bellRepeatCountのゲッターは1、2、3以外を返すことができません。基底の@AppStorageに0や47を書き込んでも、許容範囲に黙ってクランプされます。
@ObservationIgnored @AppStorage("bellRepeatCount") private var _bellRepeatCount = 1 /// Validated bell repeat count (1-3) var bellRepeatCount: Int { get { max(1, min(3, _bellRepeatCount)) } set { _bellRepeatCount = max(1, min(3, newValue)) } }
Returnは2.99ドルです。一度支払って、あなたのものになります。サポートすべきサーバーコストはなく、更新するサブスクリプションもなく、あなたが何をしているかを監視するアナリティクスパイプラインもありません。製品が製品です。なぜ私がこのようにアプリを作り続けるかの長い版が読みたければ、Minimum Worthy ProductとThe Steve Testを読んでください。短い版はこのセクションにあります。