FlutterのCustomPainterで「エクササイズのお手本アニメーション」を実装した話〜座標ベースの棒人間で32種目分の動きを表現する〜

Flutter

筆者はRPG風フィットネスアプリ「MUSCLE QUEST」をFlutterで個人開発し、Google Playにリリースしました。このアプリのコア機能はHIITタイマーで、以前の記事ではCustomPainterとRiverpod StateNotifierを組み合わせて円形タイマーを自作した話を書きました。

今回はその続編です。タイマー画面には種目名がテキストで表示されているのですが、「ジャンプランジ」と言われても初めて見る人には動きがイメージしづらい、という課題がありました。イラストや動画素材を用意する余裕はなかったので、CustomPainterで座標を打った棒人間を動かす方法で解決しました。31種目、実質32パターンの動きを、画像を1枚も使わずに表現しています。

ポーズを「座標データ」として設計する

なぜ画像やLottieではなく座標なのか

エクササイズごとにイラストを描くのは工数的に非現実的で、Lottieもアニメーターへの外注か自作が必要になります。個人開発でここに時間をかけたくなかったので、関節の座標を打つだけでポーズが作れる仕組みにしました。

やることは単純で、頭・首・肩・肘・手・腰・膝・足の13点をOffsetで置くだけです。

dart
PoseKeyframe get mirrored {
  Offset flip(Offset p) => Offset(poseDesignSize.width - p.dx, p.dy);
  return PoseKeyframe(
    head: flip(head),
    leftShoulder: flip(rightShoulder),
    rightShoulder: flip(leftShoulder),
    // ...左右のフィールドを入れ替えつつx座標を反転
  );
}

31種目分のポーズを手作業で座標設計しましたが、この仕組みのおかげで実際に打った座標セットは半分程度で済みました。

「正面視点」と「真横視点」を同じ13点で使い回す

意外とハマりどころだったのがここです。スクワットやジャンピングジャックは正面から見た構図で腕と脚を左右に開閉させますが、プランクやプッシュアップは真横から見た構図で、頭が画面の端、足が反対の端にきます。

最初は「leftShoulderは本当に体の左肩でないといけないのでは」と律儀に考えていましたが、結局Painter側は13点をただ線で結んでいるだけで、解剖学的な意味は一切気にしていません。であれば、プランクではleftShoulderrightShoulderを「奥の腕」「手前の腕」として好きな位置に置いてよいと割り切ったことで、一気に設計が楽になりました。姿勢ごとに「その姿勢が正しく見えるかどうか」だけを基準に座標を打てばよく、視点の一貫性を諦めたのが今回のいちばんの学びです。

CustomPainterで棒人間を描画する

関節座標が揃えば、あとは線で結ぶだけです。

dart
class StickFigurePainter extends CustomPainter {
  final PoseKeyframe pose;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    final scale = (size.width / poseDesignSize.width <
            size.height / poseDesignSize.height)
        ? size.width / poseDesignSize.width
        : size.height / poseDesignSize.height;
    final dx = (size.width - poseDesignSize.width * scale) / 2;
    final dy = (size.height - poseDesignSize.height * scale) / 2;
    Offset t(Offset p) => Offset(p.dx * scale + dx, p.dy * scale + dy);

    final limbPaint = Paint()
      ..color = color
      ..strokeWidth = 9 * scale
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..style = PaintingStyle.stroke;

    void line(Offset a, Offset b) => canvas.drawLine(t(a), t(b), limbPaint);

    line(pose.neck, pose.hip);
    line(pose.neck, pose.leftShoulder);
    line(pose.leftShoulder, pose.leftElbow);
    line(pose.leftElbow, pose.leftHand);
    // 右腕・両脚も同様に接続
    canvas.drawCircle(t(pose.head), 17 * scale, Paint()..color = color);
  }
}

strokeCap: StrokeCap.roundstrokeJoin: StrokeJoin.roundを指定するだけで、関節がつながって見える柔らかいシルエットになります。ウィジェットの実サイズは呼び出し側で決めればいいので、Painter自体はデザイン空間の座標系だけを知っていればよく、責務がきれいに分離できました。

AnimationControllerでキーフレーム間を補間する

1種目につき2〜4個のPoseKeyframeを用意し、AnimationControllerのvalue(0.0〜1.0)を等分してキーフレーム間を線形補間します。

dart
PoseKeyframe _poseAt(List<PoseKeyframe> frames, double t) {
  final n = frames.length;
  final scaled = t * n;
  final index = scaled.floor() % n;
  final nextIndex = (index + 1) % n;
  final localT = scaled - scaled.floor();
  return PoseKeyframe.lerp(frames[index], frames[nextIndex], localT);
}

repeat()で無限ループさせているだけなので、最後のキーフレームから最初のキーフレームへも自動的に補間されます。バーピーのような「立つ→しゃがむ→プランク→(ループ)」という3段階の動きも、特別な分岐なしにこの仕組みだけで自然に繋がります。

種目によってテンポも変えたくなったので、ExercisePoseSetcycleDurationを持たせました。ジャンプ系は600〜800msで素早く、プランクのような静止系は2200msでゆっくり「呼吸」するような微振動だけを入れています。

dart
class ExercisePoseSet {
  final List<PoseKeyframe> frames;
  final Duration cycleDuration;
  const ExercisePoseSet(this.frames, {this.cycleDuration = const Duration(milliseconds: 900)});
}

実機確認で気づいた「見えない仕様」

実装が終わって実機で動作確認していたところ、ランジのような片足種目で、渡ってくる種目名が"ランジ"ではなく"ランジ(右)"になっている瞬間がありました。ポーズのマップは"ランジ"というキーでしか登録していないので、素直に引くとヒットしません。

コードを遡ると、既存の種目説明ダイアログ側にreplaceAll(RegExp(r'\(左\)|\(右\)'), '')で末尾を剥がしてから引き直すロジックが既にありました。ポーズ検索側にも同じ正規化を入れることで解決しましたが、これは実機でタイマーを実際に回してみるまで気づけなかった仕様です。ユニットテストだけでは見えない類の不具合で、改めて実機確認の大事さを感じました。

まとめ

CustomPainterと座標データだけで、画像素材ゼロで32種目分のお手本アニメーションを実装できました。ポイントは以下の3つです。

  • 関節座標をただのOffsetの集まりとして扱い、解剖学的な正しさより「見た目の説得力」を優先する
  • 左右対称なポーズはmirroredゲッターで座標を使い回す
  • キーフレーム間の線形補間はAnimationControllerのvalueを等分するだけで十分きれいに動く

大掛かりなアニメーションライブラリを入れなくても、Flutter標準のCustomPainterとOffsetの線形補間だけでここまでできるというのは、今回あらためて実感した収穫でした。

コメント