Flutterアプリケーション開発概論

Stackで動画の上に文字やボタンを重ねる

この節で学ぶこと

前回の 4-3 では、MaterialAppScaffold を使って、Flutterアプリの土台を作りました。

前回作った画面は、黒い背景に文字を中央表示するだけのシンプルなものでした。

黒い画面
└─ 中央に「TikTok風アプリの土台」

今回の 4-4 では、TikTok風アプリらしい画面構造に近づけます。

TikTok風の画面では、動画背景の上に、文字やボタンが重なっています。

背景動画
├─ 上部タブ
├─ 右側ボタン
└─ 下部ユーザー情報

このように、Widgetを重ねて配置するときに使うのが Stack です。

この節で大切なのは、次の一文です。

Stackは、背景の上に文字やボタンを重ねるためのWidgetである。

TikTok風UIは「重なり」でできている

まず、TikTok風の画面を思い出してみましょう。

画面全体に動画が表示されています。

その上に、上部のタブがあります。

右側には、いいね、コメント、保存、共有ボタンが並んでいます。

下部には、ユーザー名、説明文、音源情報があります。

この画面は、縦に並べるだけでは作れません。

なぜなら、動画の上に、文字やボタンが重なっているからです。

Column だけでは、上から順番に並ぶだけです。

Row だけでは、横に並ぶだけです。

動画の上にUIを重ねるには、Stack が必要になります。

新しい言葉:Stackとは何か

Stack は、Widgetを重ねて配置するためのWidgetです。

初心者向けには、次のように理解すると分かりやすいです。

Stack = 画面の上に、透明なシートを何枚も重ねるようなWidget

たとえば、次のような重なりを作れます。

一番下:背景
その上:半透明の黒いフィルター
その上:文字
その上:ボタン

Flutterでは、次のように書きます。

Stack(
  children: [
    背景,
    文字,
    ボタン,
  ],
)

Stack の中では、後に書いたWidgetほど上に重なります。

Stack(
  children: [
    1番目:一番下
    2番目:その上
    3番目:さらに上
  ],
)

Stackの基本コード

まず、Stack の最小コードを見てみます。

Stack(
  children: [
    Container(
      color: Colors.black,
    ),
    Center(
      child: Text(
        '動画背景エリア',
        style: TextStyle(color: Colors.white),
      ),
    ),
  ],
)

この場合、構造は次のようになります。

Stack
├─ Container 黒背景
└─ Center
   └─ Text

Container が下に敷かれ、その上に Text が重なります。

TikTok風アプリでは、この考え方を使って、背景動画の上にUIを重ねていきます。

新しい言葉:Positionedとは何か

Stack の中で、部品の位置を細かく指定したいときに使うのが Positioned です。

たとえば、右側にボタンを置きたい場合です。

Positioned(
  right: 16,
  bottom: 120,
  child: ActionBarPractice(),
)

これは、次の意味です。

右から16px
下から120px
の位置にActionBarPracticeを置く

Positioned では、次のような位置を指定できます。

指定意味
top上からの距離
bottom下からの距離
left左からの距離
right右からの距離
child配置したいWidget

TikTok風アプリでは、次のように使います。

配置したいもの指定の考え方
上部タブtop, left, right
右側ボタンright, bottom
下部情報left, right, bottom
背景Positioned.fill

新しい言葉:Positioned.fillとは何か

Positioned.fill は、Stackの中でWidgetを親いっぱいに広げる書き方です。

Positioned.fill(
  child: Container(
    color: Colors.black,
  ),
)

これは、次の意味です。

Stackの中いっぱいにContainerを広げる

背景動画や背景色を画面全体に広げたいときに使います。

TikTok風アプリでは、背景動画を全画面に表示したいので、最終的には次のような形になります。

Positioned.fill(
  child: VideoBackground(),
)

今回の練習では、まだ本物の動画ではなく、動画エリアを表す仮の背景を置きます。

まずTikTok風の配置だけ作る

この節では、まだ動画再生はしません。

まずは、TikTok風UIの配置を作ります。

作る部品は、次の4つです。

Stack
├─ VideoPlaceholder
├─ TopNavigationPractice
├─ RightActionBarPractice
└─ BottomVideoInfoPractice

それぞれの役割は、次の通りです。

部品役割
VideoPlaceholder動画背景の仮エリア
TopNavigationPractice上部の「フォロー中」「おすすめ」「検索」
RightActionBarPractice右側のボタン一覧
BottomVideoInfoPractice下部のユーザー名・説明文・音源情報

このように、先に配置を作ってから、後で本物の動画や動きを追加します。

この節で手を動かすコード

DartPadに、次のコードを貼り付けてください。

import 'package:flutter/material.dart';

void main() {
  runApp(const TikTokStackPracticeApp());
}

class TikTokStackPracticeApp extends StatelessWidget {
  const TikTokStackPracticeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: StackPracticePage(),
    );
  }
}

class StackPracticePage extends StatelessWidget {
  const StackPracticePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: const [
          Positioned.fill(
            child: VideoPlaceholder(),
          ),
          Positioned(
            top: 52,
            left: 0,
            right: 0,
            child: TopNavigationPractice(),
          ),
          Positioned(
            right: 16,
            bottom: 112,
            child: RightActionBarPractice(),
          ),
          Positioned(
            left: 16,
            right: 92,
            bottom: 36,
            child: BottomVideoInfoPractice(),
          ),
        ],
      ),
    );
  }
}

class VideoPlaceholder extends StatelessWidget {
  const VideoPlaceholder({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [
            Color(0xFF222222),
            Color(0xFF050505),
          ],
        ),
      ),
      child: Center(
        child: Container(
          width: 230,
          height: 360,
          decoration: BoxDecoration(
            color: Colors.white12,
            borderRadius: BorderRadius.circular(28),
            border: Border.all(
              color: Colors.white24,
              width: 1,
            ),
          ),
          child: const Center(
            child: Text(
              'ここに動画が入る',
              style: TextStyle(
                color: Colors.white,
                fontSize: 22,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class TopNavigationPractice extends StatelessWidget {
  const TopNavigationPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      bottom: false,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 18),
        child: Row(
          children: [
            const Icon(
              Icons.live_tv_rounded,
              color: Colors.white,
              size: 24,
            ),
            const Spacer(),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  'フォロー中',
                  style: TextStyle(
                    color: Colors.white.withOpacity(0.6),
                    fontSize: 15,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(width: 18),
                const Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(
                      'おすすめ',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    SizedBox(height: 4),
                    SizedBox(
                      width: 28,
                      height: 3,
                      child: DecoratedBox(
                        decoration: BoxDecoration(
                          color: Colors.white,
                          borderRadius: BorderRadius.all(
                            Radius.circular(999),
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ],
            ),
            const Spacer(),
            const Icon(
              Icons.search_rounded,
              color: Colors.white,
              size: 28,
            ),
          ],
        ),
      ),
    );
  }
}

class RightActionBarPractice extends StatelessWidget {
  const RightActionBarPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: const [
        ProfileButtonPractice(),
        SizedBox(height: 22),
        ActionButtonPractice(
          icon: Icons.favorite_rounded,
          label: '1.3万',
        ),
        SizedBox(height: 20),
        ActionButtonPractice(
          icon: Icons.mode_comment_rounded,
          label: '324',
        ),
        SizedBox(height: 20),
        ActionButtonPractice(
          icon: Icons.bookmark_rounded,
          label: '1.2K',
        ),
        SizedBox(height: 20),
        ActionButtonPractice(
          icon: Icons.reply_rounded,
          label: '856',
        ),
      ],
    );
  }
}

class ProfileButtonPractice extends StatelessWidget {
  const ProfileButtonPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 54,
      height: 64,
      child: Stack(
        alignment: Alignment.topCenter,
        children: [
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(
              color: Colors.pink,
              shape: BoxShape.circle,
              border: Border.all(
                color: Colors.white,
                width: 2,
              ),
            ),
            child: const Icon(
              Icons.person_rounded,
              color: Colors.white,
              size: 28,
            ),
          ),
          Positioned(
            bottom: 6,
            child: Container(
              width: 22,
              height: 22,
              decoration: const BoxDecoration(
                color: Color(0xFFFF2D55),
                shape: BoxShape.circle,
              ),
              child: const Icon(
                Icons.add_rounded,
                color: Colors.white,
                size: 18,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class ActionButtonPractice extends StatelessWidget {
  const ActionButtonPractice({
    super.key,
    required this.icon,
    required this.label,
  });

  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(
          icon,
          color: Colors.white,
          size: 34,
          shadows: const [
            Shadow(
              color: Colors.black54,
              blurRadius: 8,
            ),
          ],
        ),
        const SizedBox(height: 4),
        Text(
          label,
          style: const TextStyle(
            color: Colors.white,
            fontSize: 12,
            fontWeight: FontWeight.bold,
            shadows: [
              Shadow(
                color: Colors.black87,
                blurRadius: 8,
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class BottomVideoInfoPractice extends StatelessWidget {
  const BottomVideoInfoPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      top: false,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            '@pet_cafe_diary',
            style: TextStyle(
              color: Colors.white,
              fontSize: 16,
              fontWeight: FontWeight.bold,
              shadows: [
                Shadow(
                  color: Colors.black87,
                  blurRadius: 8,
                ),
              ],
            ),
          ),
          const SizedBox(height: 8),
          const Text(
            '小さな命の動きは、見ているだけで少しやさしい気持ちになる。今日はペットカフェ風の癒し動画。',
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(
              color: Colors.white,
              fontSize: 14,
              height: 1.35,
              shadows: [
                Shadow(
                  color: Colors.black87,
                  blurRadius: 8,
                ),
              ],
            ),
          ),
          const SizedBox(height: 10),
          Row(
            children: const [
              Icon(
                Icons.music_note_rounded,
                color: Colors.white,
                size: 17,
              ),
              SizedBox(width: 6),
              Expanded(
                child: Text(
                  'Healing Cafe Sound - Pet Cafe Diary',
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 13,
                    fontWeight: FontWeight.w600,
                    shadows: [
                      Shadow(
                        color: Colors.black87,
                        blurRadius: 8,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

実行して確認すること

このコードを実行すると、動画はまだ流れません。

しかし、TikTok風の画面構造が見えるようになります。

画面全体
├─ 中央に動画エリア
├─ 上部にナビゲーション
├─ 右側にボタン一覧
└─ 下部に投稿情報

今回確認してほしいのは、次の3つです。

1. Stackで画面を重ねている
2. Positionedで場所を指定している
3. 画面を小さなWidgetに分けている

この3つが分かると、TikTok風UIの基本構造が理解できます。

コードの構造を分解する

今回のコード全体は、次のような構造です。

TikTokStackPracticeApp
└─ MaterialApp
   └─ StackPracticePage
      └─ Scaffold
         └─ Stack
            ├─ VideoPlaceholder
            ├─ TopNavigationPractice
            ├─ RightActionBarPractice
            │  ├─ ProfileButtonPractice
            │  └─ ActionButtonPractice
            └─ BottomVideoInfoPractice

複雑そうに見えますが、やっていることはシンプルです。

背景を置く
上にナビを置く
右にボタンを置く
下に説明文を置く

このように、Flutterでは画面を構造として考えます。

StackPracticePageの解説

中心になるのは、StackPracticePage です。

class StackPracticePage extends StatelessWidget {
  const StackPracticePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: const [
          Positioned.fill(
            child: VideoPlaceholder(),
          ),
          Positioned(
            top: 52,
            left: 0,
            right: 0,
            child: TopNavigationPractice(),
          ),
          Positioned(
            right: 16,
            bottom: 112,
            child: RightActionBarPractice(),
          ),
          Positioned(
            left: 16,
            right: 92,
            bottom: 36,
            child: BottomVideoInfoPractice(),
          ),
        ],
      ),
    );
  }
}

ここでは、ScaffoldbodyStack を置いています。

body: Stack(
  children: [...]
)

つまり、この画面の中心は Stack です。

Stackの中の順番

今回の Stack の中身は、次の順番です。

children: const [
  Positioned.fill(
    child: VideoPlaceholder(),
  ),
  Positioned(
    top: 52,
    left: 0,
    right: 0,
    child: TopNavigationPractice(),
  ),
  Positioned(
    right: 16,
    bottom: 112,
    child: RightActionBarPractice(),
  ),
  Positioned(
    left: 16,
    right: 92,
    bottom: 36,
    child: BottomVideoInfoPractice(),
  ),
],

これは、次のように重なります。

一番下:VideoPlaceholder
その上:TopNavigationPractice
その上:RightActionBarPractice
一番上:BottomVideoInfoPractice

背景になるものは、先に書きます。

上に表示したいものは、後に書きます。

Stackでは、後に書いたWidgetほど上に重なる。

VideoPlaceholderの役割

VideoPlaceholder は、まだ本物の動画ではありません。

動画が入る予定の場所を仮に表しています。

class VideoPlaceholder extends StatelessWidget {
  const VideoPlaceholder({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [
            Color(0xFF222222),
            Color(0xFF050505),
          ],
        ),
      ),
      child: Center(
        child: Container(
          width: 230,
          height: 360,
          ...
        ),
      ),
    );
  }
}

後の節で、この VideoPlaceholder を本物の動画表示に置き換えていきます。

現段階では、次の意味を持ちます。

ここに将来、動画背景が入る。

TopNavigationPracticeの役割

TopNavigationPractice は、画面上部のナビゲーション部分です。

ライブアイコン    フォロー中  おすすめ    検索

コードでは、Row を使って横に並べています。

Row(
  children: [
    Icon(...),
    Spacer(),
    Row(...),
    Spacer(),
    Icon(...),
  ],
)

ここで新しく出てくるのが Spacer です。

新しい言葉:Spacerとは何か

Spacer は、余ったスペースを埋めるためのWidgetです。

Row(
  children: [
    Icon(Icons.live_tv),
    Spacer(),
    Text('おすすめ'),
    Spacer(),
    Icon(Icons.search),
  ],
)

このように書くと、左のアイコン、中央の文字、右のアイコンがバランスよく配置されます。

初心者向けには、次のように理解してください。

Spacer = 余った空間を広げて、部品の位置を調整するWidget

RightActionBarPracticeの役割

RightActionBarPractice は、右側に並ぶボタン一覧です。

class RightActionBarPractice extends StatelessWidget {
  const RightActionBarPractice({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: const [
        ProfileButtonPractice(),
        SizedBox(height: 22),
        ActionButtonPractice(
          icon: Icons.favorite_rounded,
          label: '1.3万',
        ),
        ...
      ],
    );
  }
}

Column を使って、縦に並べています。

RightActionBarPractice
├─ ProfileButtonPractice
├─ いいねボタン
├─ コメントボタン
├─ 保存ボタン
└─ 共有ボタン

ActionButtonPractice を使うことで、同じ形のボタンを何度も再利用しています。

ActionButtonPracticeの役割

ActionButtonPractice は、アイコンと数字を縦に並べる部品です。

class ActionButtonPractice extends StatelessWidget {
  const ActionButtonPractice({
    super.key,
    required this.icon,
    required this.label,
  });

  final IconData icon;
  final String label;

ここで、iconlabel を外から受け取っています。

たとえば、いいねボタンは次のように作ります。

ActionButtonPractice(
  icon: Icons.favorite_rounded,
  label: '1.3万',
)

コメントボタンは次のように作ります。

ActionButtonPractice(
  icon: Icons.mode_comment_rounded,
  label: '324',
)

同じ ActionButtonPractice でも、渡す値を変えることで、違うボタンとして使えます。

これが、Widgetを部品化するメリットです。

新しい言葉:requiredとは何か

ActionButtonPractice では、次のように書いています。

const ActionButtonPractice({
  super.key,
  required this.icon,
  required this.label,
});

required は、「この値は必ず渡してください」という意味です。

つまり、ActionButtonPractice を使うときは、必ず iconlabel を渡す必要があります。

ActionButtonPractice(
  icon: Icons.favorite_rounded,
  label: '1.3万',
)

もし label を書き忘れると、エラーになります。

初心者向けには、次のように覚えてください。

required = この部品を作るために必ず必要な値

BottomVideoInfoPracticeの役割

BottomVideoInfoPractice は、画面下部の投稿情報を表示する部品です。

@pet_cafe_diary
小さな命の動きは...
♪ Healing Cafe Sound - Pet Cafe Diary

コードでは、Column を使って縦に並べています。

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Text('@pet_cafe_diary'),
    SizedBox(height: 8),
    Text('小さな命の動きは...'),
    SizedBox(height: 10),
    Row(...),
  ],
)

ここで出てくる crossAxisAlignment も大切です。

新しい言葉:crossAxisAlignmentとは何か

Column は、Widgetを縦に並べます。

縦に並べるとき、横方向の揃え方を決めるのが crossAxisAlignment です。

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Text('@pet_cafe_diary'),
    Text('説明文'),
  ],
)

これは、子どものWidgetを左揃えにします。

指定意味
CrossAxisAlignment.start左揃え
CrossAxisAlignment.center中央揃え
CrossAxisAlignment.end右揃え

TikTok風アプリの下部情報は左揃えなので、CrossAxisAlignment.start を使います。

SafeAreaとは何か

今回のコードには、SafeArea が出てきます。

SafeArea(
  bottom: false,
  child: Padding(
    ...
  ),
)

SafeArea は、スマホのノッチやステータスバーなどに重ならないようにするWidgetです。

たとえば、iPhoneでは画面上部にノッチがあります。

そこに文字やボタンが重なると見づらくなります。

SafeArea を使うと、安全な範囲にUIを配置できます。

初心者向けには、次のように理解してください。

SafeArea = スマホの見切れやノッチを避けるためのWidget

TikTok風アプリでは、上部ナビや下部情報に使うと安心です。

この節で覚える重要な構造

この節では、次の構造を理解できることが大切です。

Scaffold
└─ body
   └─ Stack
      ├─ 背景
      ├─ 上部ナビ
      ├─ 右側ボタン
      └─ 下部情報

これは、TikTok風UIの土台です。

今後、本物の動画再生を入れるときも、いいねボタンを動かすときも、この構造は変わりません。

まずStackで配置を作る
↓
あとから中身を本物に置き換える

手を動かす練習1:右側ボタンの位置を変える

次の部分を探してください。

Positioned(
  right: 16,
  bottom: 112,
  child: RightActionBarPractice(),
),

right を大きくしてみます。

right: 32,

右側ボタンが、少し左に移動します。

right は、右端からの距離です。

数字が大きくなるほど、右端から離れます。

手を動かす練習2:下部情報の位置を変える

次の部分を探してください。

Positioned(
  left: 16,
  right: 92,
  bottom: 36,
  child: BottomVideoInfoPractice(),
),

bottom を大きくしてみます。

bottom: 80,

下部情報が上に移動します。

bottom は、下からの距離です。

数字が大きくなるほど、上に上がります。

手を動かす練習3:背景の文字を変える

次の部分を探してください。

'ここに動画が入る'

これを次のように変えてみてください。

'動画背景エリア'

中央の文字が変わります。

後の節で、この場所に本物の動画を入れます。

手を動かす練習4:ボタンを追加する

RightActionBarPracticeColumn の中に、次のボタンを追加してみてください。

SizedBox(height: 20),
ActionButtonPractice(
  icon: Icons.send_rounded,
  label: '送信',
),

右側ボタンが1つ増えます。

この練習で、ActionButtonPractice が再利用できる部品であることを確認できます。

よくあるつまずき1:Stackの中の順番が分からない

Stack は、後に書いたWidgetほど上に重なります。

Stack(
  children: [
    背景,
    文字,
    ボタン,
  ],
)

この場合、背景が一番下、ボタンが一番上です。

TikTok風アプリでは、背景動画を一番下に置く必要があります。

そのため、背景は最初に書きます。

よくあるつまずき2:PositionedはStackの中で使う

Positioned は、基本的に Stack の中で使います。

次のような構造です。

Stack(
  children: [
    Positioned(
      right: 16,
      bottom: 112,
      child: RightActionBarPractice(),
    ),
  ],
)

ColumnRow の中でいきなり Positioned を使うと、意図した動きになりません。

まずは、次のように覚えてください。

PositionedはStackの中で使う。

よくあるつまずき3:leftとrightを両方指定する意味

次のようなコードがあります。

Positioned(
  left: 16,
  right: 92,
  bottom: 36,
  child: BottomVideoInfoPractice(),
)

leftright を両方指定すると、横幅が決まります。

左から16px
右から92px
の範囲に配置する

TikTok風アプリでは、右側にボタンがあるため、下部の説明文がボタンに重ならないように、right: 92 を指定しています。

下部テキスト
右側ボタンに重ならないように右側を空ける

よくあるつまずき4:constがつけられないことがある

今回のコードでは、children: const [...] のような書き方が出てきます。

ただし、あとで状態が変わるWidgetや、外から値を受け取るWidgetでは、const を外す必要がある場合があります。

初心者のうちは、次の方針で大丈夫です。

constをつけてエラーにならなければそのまま
エラーになったら外す
状態が変わる部分ではconstを外すことがある

この節の確認問題

確認問題1

Stack は何のために使いますか。

答え

Widgetを重ねて表示するために使います。

TikTok風アプリでは、動画背景の上に文字やボタンを重ねるために使います。

確認問題2

Positioned は何をするWidgetですか。

答え

Stack の中で、Widgetの位置を指定するためのWidgetです。

topbottomleftright などを使って配置します。

確認問題3

Positioned.fill は何をしますか。

答え

Stackの中で、子Widgetを親いっぱいに広げます。

背景動画や背景色を画面全体に広げるときに使えます。

確認問題4

Stackの中では、先に書いたWidgetと後に書いたWidgetのどちらが上に重なりますか。

答え

後に書いたWidgetほど上に重なります。

確認問題5

TikTok風UIで、Stack が重要な理由は何ですか。

答え

動画背景の上に、上部タブ、右側ボタン、下部情報を重ねて配置する必要があるからです。

確認問題6

ActionButtonPractice を作るメリットは何ですか。

答え

いいね、コメント、保存、共有のような同じ形のボタンを、1つの部品として再利用できるからです。

この節のまとめ

この節では、Stack を使って、動画の上に文字やボタンを重ねる方法を学びました。

TikTok風アプリの画面は、動画背景の上に複数のUIが重なっています。

そのため、ColumnRow だけではなく、Stack が重要になります。

Stack
├─ 背景動画
├─ 上部ナビ
├─ 右側ボタン
└─ 下部情報

また、Positioned を使うことで、部品を好きな位置に配置できます。

上部ナビ:top
右側ボタン:right + bottom
下部情報:left + right + bottom
背景:Positioned.fill

この節で一番大切なのは、次の一文です。

TikTok風UIは、Stackで背景と操作部品を重ねて作る。

次の節では、いよいよ画面に表示する動画データを ShortVideo classとして設計していきます。

教材トップへ戻る