SwiftUIで作る筋トレアプリ|回数カウント+休憩タイマーをワンタップ化した話

前回、回数を数えるだけの筋トレアプリをSwiftUIで自作しました。
記録も分析もなし。とにかく「トレ中に邪魔しない」を最優先にしたやつです。

前回の続きとして、今回は 回数カウント+休憩タイマーを“ワンタップ運用”に寄せた話を書きます。

導入:筋トレ中は「次に何押すんだっけ?」が一番の敵

筋トレ中って、画面を見る余裕がないことが多いです。

  • 手が塞がる
  • 息が上がる
  • 汗でスマホ触りたくない

この状態で、ボタンが複数あると一気に使われなくなります。

結論はシンプルで、1画面+1ボタンで状態を回すのが一番ラクでした。

この記事で得られることは以下です。

  • ワンタップ化のための「状態設計(最小)」の考え方
  • 回数カウントと休憩タイマーをつなぐ最小コード
  • 誤タップや停止漏れを減らすための実装ポイント

前提:多機能化はしない(今回も割り切り全振り)

今回の方針は前回と同じです。

  • 画面遷移なし
  • 設定なし(秒数カスタムはしない)
  • 永続化なし
  • バックグラウンド対応はしない

筋トレ中に欲しいのは、正確さより「邪魔しないこと」。
ここからブレると、また使われなくなります。

問題の正体:なぜ操作が迷いになるのか

ワンタップ化の前に、何が邪魔しているかを分解します。

  • ボタンが複数ある → 判断が増える
  • 画面が分かれる → 遷移が増える
  • 状態が見えない → いまトレ中?休憩中?が曖昧
  • 停止漏れが起きる → タイマーが裏で動き続ける事故

つまり、筋トレ中のアプリで一番避けたいのは 判断忘れ物 です。

解決策:状態を2つに潰して、メイン操作を1ボタンに寄せる

1) 状態は2つだけ(training / rest)

状態が増えるほど、ボタンと分岐が増えます。

なので、今回は割り切ってこうします。

  • training(トレ中)
  • rest(休憩中)

表示も動作も、この2つだけで回します。

2) メイン操作を「Nextボタン」1つに固定する

筋トレ中にやりたいのは、だいたいこれだけです。

  • セットが終わった → 休憩開始したい
  • 休憩が終わった → 次セットへ行きたい

なら、メイン操作は1つでいいです。

  • トレ中に押す → 休憩開始(タイマーStart)
  • 休憩中に押す → 次セットへ(タイマーStop)

ボタンのラベルは状態で変えます。

  • training:休憩開始
  • rest:次セットへ

これだけで「次に何押すか」が消えます。

3) 停止漏れはUIで防がず、コード側で潰す

停止ボタンを置くと、押し忘れます。

なので、停止は「状態遷移で必ず走る」ように寄せます。

  • training に戻るタイミングで invalidate
  • 画面が消えるときも invalidate

筋トレ中に“自分の注意力”に頼る設計は負けです。

コードサンプル:回数+休憩をワンタップで回す最小構成

状態定義

import SwiftUI
import AudioToolbox

enum Mode {
case training
case rest
}

struct ContentView: View {
@State private var mode: Mode = .training
@State private var repCount: Int = 0

@State private var restSeconds: Int = 60
@State private var remainingTime: Int = 60
@State private var timer: Timer?


メインボタン(これが主役)

    private func handleMainButtonTap() {
switch mode {
case .training:
// 休憩開始
mode = .rest
startRestTimer()

case .rest:
// 次セットへ
stopTimer()
mode = .training
remainingTime = restSeconds
// 必要なら回数をリセット(方針次第)
// repCount = 0
}
}

タイマー開始・停止+音

    private func startRestTimer() {
stopTimer()
remainingTime = restSeconds

timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
if remainingTime > 0 {
remainingTime -= 1
} else {
stopTimer()
playSound()
// 休憩終了で自動遷移させるかは好み。
// 今回は「ワンタップ運用」を優先して自動遷移はしない。
}
}
}

private func stopTimer() {
timer?.invalidate()
timer = nil
}

private func playSound() {
AudioServicesPlaySystemSound(1005)
}

1画面にまとめる(表示はmodeで切替)

    var body: some View {
VStack(spacing: 16) {
Text(mode == .training ? "トレーニング" : "休憩")
.font(.headline)

if mode == .training {
Text("\(repCount)")
.font(.system(size: 72, weight: .bold))
} else {
Text("\(remainingTime) 秒")
.font(.system(size: 56, weight: .bold))
}

HStack(spacing: 12) {
Button("+1") { repCount += 1 }
.buttonStyle(.bordered)

Button("−1") { repCount = max(0, repCount - 1) }
.buttonStyle(.bordered)
}

Button(mode == .training ? "休憩開始" : "次セットへ") {
handleMainButtonTap()
}
.buttonStyle(.borderedProminent)

Button("リセット") {
stopTimer()
mode = .training
repCount = 0
remainingTime = restSeconds
}
.buttonStyle(.bordered)
}
.padding()
.onDisappear {
stopTimer()
}
}
}

これで、運用はこうなります。

  • セット終わりに「休憩開始」
  • 音が鳴ったら「次セットへ」

筋トレ中に考えることが増えないのが一番の勝ちです。

チェックリスト(実装で守るルール)

  • 状態は training/rest の2つだけ
  • 画面遷移なし、1画面で完結
  • メイン操作は1ボタン(状態でラベル変更)
  • 状態が戻る経路で timer を必ず止める
  • 画面破棄でも timer を止める(onDisappear)

具体例(筋トレ中の運用フロー)

筋トレ中の操作は、これだけで回ります。

  • セットが終わる → 休憩開始
  • 休憩が終わる(音が鳴る) → 次セットへ
  • 回数は「+1」だけで増やす

「次に何を押すか」を考えない設計が、継続に直結します。

次にやるなら(でも、必要になるまでやらない)

ワンタップ運用を壊さずに伸ばすなら、次はこの程度が限界です。

  • 秒数を3パターン固定で切替(自由入力は沼)
  • トレ開始をもっと雑に(ボタン配置の最適化)

ただ、必要になるまでやりません。

筋トレアプリは“育てる”より“使われる”が正義です。

関連記事

筋トレアプリがサービス終了。回数を数えるだけのiOSアプリを自作した話

SwiftUIで作る筋トレアプリ|回数カウント+音だけのインターバルタイマーを追加した話

おわりに

筋トレ中に欲しいのは、機能ではなく 迷いゼロ です。

状態を2つに潰して、メイン操作を1ボタンに寄せる。

これだけで、回数カウント+休憩タイマーが「現場で使える形」になります。

本日はここまで。では、また次の記事でお会いしましょう。

おすすめの記事