はじめに
はじめまして!
今回、Flutterで動作分析アプリを開発した際のハマって悩んだことを記事にしました。
画面に描画する際になぞった位置とずれてしまうという問題が解決できず長い間悩みました。
同じような問題で悩んでいる方の参考になれば幸いです。
追記(2024.11.25)
描画機能に関して一部修正しました
以下の記事をご覧ください。
今回作成したアプリ
まず初めに今回作成した動作分析アプリについてご紹介します。
今回作成したのは動画内に自由に描画できるアプリです。
フリーハンドでの描画や直線の描画、角度の表示などが行えます。
そのような機能を実装することで、スポーツの動作などで姿勢や関節の角度をすぐに表示することができ、動作確認に役に立つと考えました。
他にも過去の動画を保管したり、動画の詳細情報(日付、タイトル、メモ、カテゴリ)を保存できます。
また、動画の再生だけではなく、コマ送り、再生速度調整の機能を実装しました。
詳しくはYouTube Shortに再生リストでまとめていますので、ぜひご覧ください。↓
開発環境
今回の開発環境は以下の通りです。
・Windows 11
・Flutter 3.22
・Dart 3.4
・Firebase 12.7
・Android Studio 17.0
ハマったこと
「画面に描画する際になぞった位置とずれてしまう」
エミュレーターで確認した際にカーソルの位置と実際に描画される位置にずれがありました。
Android実機で確認しても同じ結果でした。
この写真は動画の四辺に沿って直線を描画したものです。
このように左右と上方向に余白ができています。
そのため描画をしても画面下方向に集まり、僅かにずれてしまいます。
実装方法
最初にこの機能を実装した時のコードは以下の通りです。
Offset _scalePosition(Offset localPosition, Size widgetSize) {
final videoSize = _controller!.value.size;
// アスペクト比を計算
final double videoAspectRatio = videoSize.width / videoSize.height;
final double widgetAspectRatio = widgetSize.width / widgetSize.height;
// スケーリングを計算
double scale;
double offsetX = 0;
double offsetY = 0;
if (widgetAspectRatio > videoAspectRatio) {
// 横画面の場合
scale = widgetSize.height / videoSize.height;
offsetX = (widgetSize.width - videoSize.width * scale) / 2;
} else {
// 縦画面の場合
scale = widgetSize.width / videoSize.width;
offsetY = (widgetSize.height - videoSize.height * scale) / 2;
}
// オフセットとスケールを使って座標を調整
final double adjustedX = (localPosition.dx - offsetX) / scale;
final double adjustedY = (localPosition.dy - offsetY) / scale;
// 画面外にはみ出さないように制限
final double clampedX = adjustedX.clamp(0.0, videoSize.width);
final double clampedY = adjustedY.clamp(0.0, videoSize.height);
return Offset(clampedX, clampedY);
}
解説
final videoSize = _controller!.value.size;
final double videoAspectRatio = videoSize.width / videoSize.height;
final double widgetAspectRatio = widgetSize.width / widgetSize.height;
videoSizeを取得して、動画とwidgetのアスペクト比(横幅/高さ)を計算します。
これにより動画とwidgetの縦横比の違いを確認します。
double scale;
double offsetX = 0;
double offsetY = 0;
if (widgetAspectRatio > videoAspectRatio) {
// 横画面の場合
scale = widgetSize.height / videoSize.height;
offsetX = (widgetSize.width - videoSize.width * scale) / 2;
} else {
// 縦画面の場合
print('Windgetsize:$widgetSize');
scale = widgetSize.width / videoSize.width;
offsetY = (widgetSize.height - videoSize.height * scale) / 2;
}
scaleはvideoSizeをwidgetSizeに合わせるための倍率です。
offsetXとoffsetYは描画位置の調整のための値です。
widgetが横長か縦長かで処理を分けています。
・横画面の場合
widgetが動画よりも横長の場合、動画の高さとwidgetの高さに合わせてスケールします。
そしてwidgetの横幅からスケール後の動画の横幅を引いて2で割ることで左右の余白を計算します。
・縦画面の場合
widgetが動画よりも縦長の場合、動画の横幅とwidgetの横幅に合わせてスケールします。
そしてwidgetの高さからスケール後の動画の高さを引いて2で割ることで上下の余白を計算します。
final double adjustedX = (localPosition.dx - offsetX) / scale;
final double adjustedY = (localPosition.dy - offsetY) / scale;
画面にタッチした位置(localPosition)の座標から、余白(offset)を引いてスケーリングで動画のサイズに合わせた座標に変換します。
final double clampedX = adjustedX.clamp(0.0, videoSize.width);
final double clampedY = adjustedY.clamp(0.0, videoSize.height);
return Offset(clampedX, clampedY);
描画位置が動画の外にはみ出さないようにX座標、Y座標をそれぞれ動画の幅と高さの範囲内に制限します。
最後に制限された描画位置をOffsetとして返します。
このようにwidgetのサイズと動画のサイズを合わせて余白を計算することで描画位置の座標を求めていましたが、先ほどの画像のように少しずれてしまっていました。
解決方法
動画表示領域の高さ取得
@override
Widget build(BuildContext context) {
final bool isControllerInitialized =
_controller != null && _controller!.value.isInitialized;
return Scaffold(
backgroundColor: AppColors.backgroundColor,
appBar: PreferredSize(
preferredSize: Size.fromHeight(35.0),
child: AppBar(
backgroundColor: AppColors.textInputColor,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios),
onPressed: () {
Navigator.pop(context);
},
),
),
),
resizeToAvoidBottomInset: true,
body: OrientationBuilder(
builder: (context, orientation) {
return LayoutBuilder(
builder: (context, constraints) {
// ウィジェットの最大サイズを取得
- final widgetSize = Size(constraints.maxWidth, constraints.maxHeight);
+ final widgetSize = Size(constraints.maxWidth, constraints.maxHeight - 60);
final double videoAspectRatio = _controller!.value.aspectRatio;
return Column(
children: [
if (isControllerInitialized)
Expanded(
child: GestureDetector(
onPanStart: (details) => _onPanStart(details, widgetSize),
onPanUpdate: (details) => _onPanUpdate(details, widgetSize),
onPanEnd: (details) => _onPanEnd(details, widgetSize),
child: Center(
child: AspectRatio(
// アスペクト比を維持して動画を表示
aspectRatio: videoAspectRatio,
child: FittedBox(
fit: orientation == Orientation.portrait
? BoxFit.cover
: BoxFit.contain,
alignment: Alignment.center,
child: Stack(
alignment: Alignment.center,
children: [
// 動画再生領域
SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
// CustomPaint で描画
CustomPaint(
painter: DrawingPainter(_drawings),
size: Size(
_controller!.value.size.width,
_controller!.value.size.height,
),
),
],
),
),
),
),
),
),
if (isControllerInitialized)
VideoProgressIndicator(
_controller!,
allowScrubbing: true,
),
// 縦向きと横向きで異なるコントロールを表示
orientation == Orientation.portrait
? _buildVerticalControls() // 縦向き
: _buildHorizontalControls(), // 横向き
],
);
},
);
},
),
);
}
LayoutBuilderでconstraints.maxWidth と constraints.maxHeight によって、親ウィジェットや画面全体のサイズに応じた最大幅と最大高さを取得しています。この widgetSize をもとに先ほどの描画処理を行います。
widgetの最大高を取得する際に、修正後では再生ボタンなどのアイコンの高さを考慮して値を調整しています。
この処理のおかかで動画表示領域の高さを取得しました。
描画位置調整
if (widgetAspectRatio > videoAspectRatio) {
// 横画面の場合
scale = widgetSize.height / videoSize.height;
offsetX = (widgetSize.width - videoSize.width * scale) / 2;
+ offsetY = 60;
} else {
// 縦画面の場合
scale = widgetSize.width / videoSize.width;
- offsetY = (widgetSize.height - videoSize.height * scale) / 2;
+ offsetY = (widgetSize.height + ( widgetSize.height * 0.04 ) - videoSize.height * scale) / 2;
}
横画面と縦画面ともに上下のずれを解消するためにoffsetYの値を調整しました。
結果
同様に四隅に沿って線を引くとなぞった位置に描画されました。
エミュレーターでPixel Tablet (10.95インチ)、Pixel 4 (5.7インチ)を用いて行っても同じ結果でした。