本記事は下記リンクのFlutter公式ドキュメントを学習したメモです。

Layout in Flutter

今回は上記リンクのレイアウトの中でScrollingセクションの内容を紹介する。

フローティング・アプリ・バー

アプリ・バーの背景にイメージなどを表示して見栄えを良くしたい時もある。

だが、常にアプリ・バーのサイズが大きいとコンテンツが見えるところが狭く、みにくくなる。

その時は柔軟にアプリ・バーのサイズが変わると便利だと思う。

今回は、公式ドキュメントのサンプルを確認しながら、この問題の解決策を見ていく。

下のAppBarで使用するイメージはunsplashにあるChen Liuさんの作品です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import 'package:flutter/material.dart';

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

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    const title = 'Floating App Bar';
    return MaterialApp(
      title: title,
      home: Scaffold(
        body: CustomScrollView(
          slivers: [
            SliverAppBar(
              title: Text(
                title,
                style: TextStyle(
                  color: Colors.amber[400],
                ),
              ),
              floating: true,
              flexibleSpace: Stack(
                children: <Widget>[
                  Positioned.fill(
                    child: Image.asset(
                      'assets/images/space.jpg',
                      fit: BoxFit.cover,
                    ),
                  ),
                ],
              ),
              expandedHeight: 200,
            ),
            SliverList(
              delegate: SliverChildBuilderDelegate(
                (context, index) => ListTile(
                  leading: const Icon(Icons.check_box_outlined),
                  title: Text('アイテム #${index + 1}'),
                ),
                childCount: 100,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

上のコードを実行すると最初はイメージが表示されたアプリ・バーが大きく表示される。

iOS simulator

スクロールすると下のように徐々にアプリ・バーが小さくなり、なくなる。

iOS simulator

iOS simulator

iOS simulator

スクロール時差効果

スクロール時差効果とは公式ドキュメントに載っている下のイメージを見ればわかりやすいと思う。

ParallaxScrolling

このような時差効果はイメージを含むカードを作成してカードがスライドされる時に各カード内のイメージを反対にスライドすることで作れる。

リスト用のWidget

まずはリスト用のクラスを作ってみる。

/libの下にwidgetsフォルダを作成してそこにparallax_recipe.dartファイルを作成する。

ParallaxRecipeクラスは下の内容だ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return const SingleChildScrollView(
      child: Column(
        children: [],
      ),
    );
  }
}

縦にアイテムが並ぶのでColumnを使う。また、縦スクロールできるようにするためにColumnをSingleChildScrollViewの子Widgetにする。

アイテム用のWidget

各アイテムは角丸長方形で背景にはイメージがある。

イメージ上に重なってタイトルなどが表示される。タイトル・テキストには影のようなグラデーションを表示して可読性をあげる。

まずは基本的な骨組みを作ってみよう。/lib/widgetsの下にlocation_list_item.dartを作成する。

LocationListItemクラスは下の内容だ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import 'package:flutter/material.dart';

class LocationListItem extends StatelessWidget {
  const LocationListItem({
    super.key,
    required this.imageUrl,
    required this.name,
    required this.country,
  });

  final String imageUrl;
  final String name;
  final String country;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
      // 子Widgetを16/9の比率に合わせる
      child: AspectRatio(
        aspectRatio: 16 / 9,
        // 角丸四角にクリップする
        child: ClipRRect(
          borderRadius: BorderRadius.circular(16),
          child: Stack(
            children: [
              // 背景イメージ
              _buildParallaxBacground(context),
              // グラデーション効果
              _buildGradient(),
              // タイトル表示
              _buildTitleAndSubtitle(),
            ],
          ),
        ),
      ),
    );
  }

  // 背景イメージ作成用
  Widget _buildParallaxBacground(BuildContext context) {
    return Positioned.fill(
      child: Image.network(
        imageUrl,
        fit: BoxFit.cover,
      ),
    );
  }

  // グラデーション効果
  Widget _buildGradient() {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            // 透明から黒の70%透明まで
            colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
            // 上から
            begin: Alignment.topCenter,
            // 下へ
            end: Alignment.bottomCenter,
            // グラデーション範囲
            stops: const [0.6, 0.95],
          ),
        ),
      ),
    );
  }

  // タイトル表示用
  Widget _buildTitleAndSubtitle() {
    return Positioned(
      left: 20,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            name,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          Text(
            country,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 14,
            ),
          ),
        ],
      ),
    );
  }
}

アイテム用のクラスを作ったので先ほど作成したリスト用のParallaxRecipeクラスに追加してみよう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import 'package:flutter/material.dart';
import 'package:list_and_grids/widgets/location_list_item.dart';

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

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          for (final location in locations)
            LocationListItem(
              imageUrl: location.imageUrl,
              name: location.name,
              country: location.place,
            ),
        ],
      ),
    );
  }
}

まだlocationsが定義されてないのでエラーになるが、大丈夫これから作るから。

時差効果

時差効果はFlowを使う。

LocationListItemクラスの下にFlowで使うdelegate用のParallaxFlowDelegateクラスを作成する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate();

  @override
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    // TODO: implement getConstraintsForChild
    return super.getConstraintsForChild(i, constraints);
  }

  @override
  void paintChildren(FlowPaintingContext context) {
    // TODO: implement paintChildren
  }

  @override
  bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
    // TODO: implement shouldRepaint
    throw true;
  }
}

必要なメソッドをオーバーライドしておく。

次にLocationListItemの背景イメージをFlowで囲んんで上記のクラスを設定する。

1
2
3
4
5
6
7
8
9
10
11
12
  // 背景イメージ作成用
  Widget _buildParallaxBacground(BuildContext context) {
    return Flow(
      delegate: ParallaxFlowDelegate(),
      children: [
        Image.network(
          imageUrl,
          fit: BoxFit.cover,
        ),
      ],
    );
  }

FlowDelegateクラスは、子のサイズを変更する方法、子が描画される場所を制御する。

背景イメージはFlow Widgetと同じ幅である必要があるので幅を制約する。

1
2
3
4
5
6
  @override
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    return BoxConstraints.tightFor(
      width: constraints.maxWidth,
    );
  }

時差効果は、スクロール位置をもとにイメージの位置を計算して描画する必要があり、計算するためにはいくつかの情報が必要だ。

その情報をFlowDelegateに渡す必要があるのでGlobalKeyを使う。

まずはParallaxFlowDelegateクラスが情報を受け取れるように修正する。

ParallaxFlowDelegateクラス

1
2
3
4
5
6
7
8
9
10
class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate({                  // 変更
    required this.scrollable,             // 変更
    required this.listItemContext,        // 変更
    required this.backgroundImageKey,     // 変更
  });                                     // 変更

  final ScrollableState scrollable;       // 追加
  final BuildContext listItemContext;     // 追加
  final GlobalKey backgroundImageKey;     // 追加

次は情報を渡す処理を追加する

LocationListItemクラス

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final GlobalKey _backgroundImageKey = GlobalKey(); // 追加

  // 背景イメージ作成用
  Widget _buildParallaxBacground(BuildContext context) {
    return Flow(
      delegate: ParallaxFlowDelegate(               // 変更
        scrollable: Scrollable.of(context),         // 変更
        listItemContext: context,                   // 変更
        backgroundImageKey: _backgroundImageKey,    // 変更
      ),                                            // 変更
      children: [
        Image.network(
          imageUrl,
          fit: BoxFit.cover,
        ),
      ],
    );
  }

情報を渡したので計算を追加しよう。ParallaxFlowDelegateクラスの全体だ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate({
    required this.scrollable,
    required this.listItemContext,
    required this.backgroundImageKey,
  });

  final ScrollableState scrollable;
  final BuildContext listItemContext;
  final GlobalKey backgroundImageKey;

  @override
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    return BoxConstraints.tightFor(
      width: constraints.maxWidth,
    );
  }

  @override
  void paintChildren(FlowPaintingContext context) {
    final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
    final listItemBox = listItemContext.findRenderObject() as RenderBox;
    final listItemOffset = listItemBox.localToGlobal(
      listItemBox.size.centerLeft(Offset.zero),
      ancestor: scrollableBox,
    );

    final viewportDimension = scrollable.position.viewportDimension;
    final scrollFraction =
        (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

    final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

    final backgroundSize =
        (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
            .size;
    final listItemSize = context.size;
    final childRect = verticalAlignment.inscribe(
      backgroundSize,
      Offset.zero & listItemSize,
    );
    context.paintChild(
      0,
      transform:
          Transform.translate(offset: Offset(0.0, childRect.top)).transform,
    );
  }

  @override
  bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
    return scrollable != oldDelegate.scrollable ||
        listItemContext != oldDelegate.listItemContext ||
        backgroundImageKey != oldDelegate.backgroundImageKey;
  }
}

その他処理

イメージはインターネット経由でダウンロードするようになっていたが、assetsフォルダから参照するように変更した。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  // 背景イメージ作成用
  Widget _buildParallaxBacground(BuildContext context) {
    return Flow(
      delegate: ParallaxFlowDelegate(
        scrollable: Scrollable.of(context),
        listItemContext: context,
        backgroundImageKey: _backgroundImageKey,
      ),
      children: [
        Image.asset(
          imageUrl,
          key: _backgroundImageKey,
          fit: BoxFit.cover,
        ),
      ],
    );
  }

それとParallaxRecipeクラスで参照しているlocationは/lib/constantsフォルダを作ってlocation.dartファイルに定義している。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Location {
  const Location({
    required this.name,
    required this.place,
    required this.imageUrl,
  });

  final String name;
  final String place;
  final String imageUrl;
}

const urlPrefix = 'assets/images';

const locations = [
  Location(
    name: 'Mount Rushmore',
    place: 'U.S.A',
    imageUrl: '$urlPrefix/01-mount-rushmore.jpg',
  ),
  Location(
    name: 'Gardens By The Bay',
    place: 'Singapore',
    imageUrl: '$urlPrefix/02-singapore.jpg',
  ),
  Location(
    name: 'Machu Picchu',
    place: 'Peru',
    imageUrl: '$urlPrefix/03-machu-picchu.jpg',
  ),
  Location(
    name: 'Vitznau',
    place: 'Switzerland',
    imageUrl: '$urlPrefix/04-vitznau.jpg',
  ),
  Location(
    name: 'Bali',
    place: 'Indonesia',
    imageUrl: '$urlPrefix/05-bali.jpg',
  ),
  Location(
    name: 'Mexico City',
    place: 'Mexico',
    imageUrl: '$urlPrefix/06-mexico-city.jpg',
  ),
  Location(
    name: 'Cairo',
    place: 'Egypt',
    imageUrl: '$urlPrefix/07-cairo.jpg',
  ),
];

もちろんイメージをダウンロードして該当フォルダに格納するのは忘れないでもらいたい。

pubspec.yamlファイルも修正した。

1
2
3
4
5
6
7
8
  assets:
    - assets/images/01-mount-rushmore.jpg
    - assets/images/02-singapore.jpg
    - assets/images/03-machu-picchu.jpg
    - assets/images/04-vitznau.jpg
    - assets/images/05-bali.jpg
    - assets/images/06-mexico-city.jpg
    - assets/images/07-cairo.jpg

だが、時差効果は出なかった。

iOS simulator

どうやら、ParallaxFlowDelegateクラスでScrollableStateのScrollPositionをスーパークラスに渡す必要があるらしい。

1
2
3
4
5
6
class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate({
    required this.scrollable,
    required this.listItemContext,
    required this.backgroundImageKey,
  }) : super(repaint: scrollable.position);

時差効果ができた!

iOS simulator

iOS simulator

全体コード

長くなるが、全体コードを載せておく。

/lib/main.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import 'package:flutter/material.dart';
import 'package:list_and_grids/widgets/parallax_recipe.dart';

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      home: const Scaffold(
        body: Center(
          child: ParallaxRecipe(),
        ),
      ),
    );
  }
}

/lib/constants/location.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Location {
  const Location({
    required this.name,
    required this.place,
    required this.imageUrl,
  });

  final String name;
  final String place;
  final String imageUrl;
}

const urlPrefix = 'assets/images';

const locations = [
  Location(
    name: 'Mount Rushmore',
    place: 'U.S.A',
    imageUrl: '$urlPrefix/01-mount-rushmore.jpg',
  ),
  Location(
    name: 'Gardens By The Bay',
    place: 'Singapore',
    imageUrl: '$urlPrefix/02-singapore.jpg',
  ),
  Location(
    name: 'Machu Picchu',
    place: 'Peru',
    imageUrl: '$urlPrefix/03-machu-picchu.jpg',
  ),
  Location(
    name: 'Vitznau',
    place: 'Switzerland',
    imageUrl: '$urlPrefix/04-vitznau.jpg',
  ),
  Location(
    name: 'Bali',
    place: 'Indonesia',
    imageUrl: '$urlPrefix/05-bali.jpg',
  ),
  Location(
    name: 'Mexico City',
    place: 'Mexico',
    imageUrl: '$urlPrefix/06-mexico-city.jpg',
  ),
  Location(
    name: 'Cairo',
    place: 'Egypt',
    imageUrl: '$urlPrefix/07-cairo.jpg',
  ),
];

/lib/widgets/parallax_recipe.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import 'package:flutter/material.dart';
import 'package:list_and_grids/widgets/location_list_item.dart';

import '../constants/location.dart';

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

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          for (final location in locations)
            LocationListItem(
              imageUrl: location.imageUrl,
              name: location.name,
              country: location.place,
            ),
        ],
      ),
    );
  }
}

/lib/widgets/location_list_item.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import 'package:flutter/material.dart';

class LocationListItem extends StatelessWidget {
  LocationListItem({
    super.key,
    required this.imageUrl,
    required this.name,
    required this.country,
  });

  final String imageUrl;
  final String name;
  final String country;

  final GlobalKey _backgroundImageKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
      // 子Widgetを16/9の比率に合わせる
      child: AspectRatio(
        aspectRatio: 16 / 9,
        // 角丸四角にクリップする
        child: ClipRRect(
          borderRadius: BorderRadius.circular(16),
          child: Stack(
            children: [
              // 背景イメージ
              _buildParallaxBacground(context),
              // グラデーション効果
              _buildGradient(),
              // タイトル表示
              _buildTitleAndSubtitle(),
            ],
          ),
        ),
      ),
    );
  }

  // 背景イメージ作成用
  Widget _buildParallaxBacground(BuildContext context) {
    return Flow(
      delegate: ParallaxFlowDelegate(
        scrollable: Scrollable.of(context),
        listItemContext: context,
        backgroundImageKey: _backgroundImageKey,
      ),
      children: [
        Image.asset(
          imageUrl,
          key: _backgroundImageKey,
          fit: BoxFit.cover,
        ),
      ],
    );
  }

  // グラデーション効果
  Widget _buildGradient() {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            // 透明から黒の70%透明まで
            colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
            // 上から
            begin: Alignment.topCenter,
            // 下へ
            end: Alignment.bottomCenter,
            // グラデーション範囲
            stops: const [0.6, 0.95],
          ),
        ),
      ),
    );
  }

  // タイトル表示用
  Widget _buildTitleAndSubtitle() {
    return Positioned(
      left: 20,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            name,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          Text(
            country,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 14,
            ),
          ),
        ],
      ),
    );
  }
}

class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate({
    required this.scrollable,
    required this.listItemContext,
    required this.backgroundImageKey,
  }) : super(repaint: scrollable.position);

  final ScrollableState scrollable;
  final BuildContext listItemContext;
  final GlobalKey backgroundImageKey;

  @override
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    return BoxConstraints.tightFor(
      width: constraints.maxWidth,
    );
  }

  @override
  void paintChildren(FlowPaintingContext context) {
    final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
    final listItemBox = listItemContext.findRenderObject() as RenderBox;
    final listItemOffset = listItemBox.localToGlobal(
      listItemBox.size.centerLeft(Offset.zero),
      ancestor: scrollableBox,
    );

    final viewportDimension = scrollable.position.viewportDimension;
    final scrollFraction =
        (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

    final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

    final backgroundSize =
        (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
            .size;
    final listItemSize = context.size;
    final childRect = verticalAlignment.inscribe(
      backgroundSize,
      Offset.zero & listItemSize,
    );
    context.paintChild(
      0,
      transform:
          Transform.translate(offset: Offset(0.0, childRect.top)).transform,
    );
  }

  @override
  bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
    return scrollable != oldDelegate.scrollable ||
        listItemContext != oldDelegate.listItemContext ||
        backgroundImageKey != oldDelegate.backgroundImageKey;
  }
}

最後に

これでScrollセクションは終わりだ。次はAdaptive designセクションだが、これは必要に応じて参照するのでスキップする。

そういうことでLayoutセクションの終わりだ。

今後、続けてFlutter公式ドキュメントを確認していきたい。

コメントする