Dart

43.【AppleMusicクローン】セクション2 のレイアウトをListView を使って実装する

今回はセクション2の部分のレイアウトを作っていきます。

前回の記事はこちらになります。

enum でいえば、 Relax に当たる箇所です。

enum Section {
  Top,
  Relax,
  ActivityMood,
  ShortMovie,
  Daily,
  Update,
  Attention,
  New,
  Now,
  Others,
  Favorite,
  Must,
  BestInterview,
  ComingSoon
}

デザインでいえば

セクション2 のデザイン

この部分になります。
特に手元にある iPhone から Apple Music を触ってみてもそんな特殊なレイアウトではないようなので、
ListView の Axis.horizontal を使えば実現できますね。

ListView の使い方で不安な方は過去記事に簡単な使い方を説明していますので復習してみてください。

設計について

今回のアプリは API を使わずに静的なレイアウトで組みます。

ヘッダー部分とコンテンツ部分にレイアウトを分けられそうなので分けました。

f:id:qed805:20200408231328p:plain

second_section_header_item と second_section_item

  • second_sectoin_header_item (ヘッダー部分)
  • second_sectoin_item (コンテンツ部分)

使用する画像リソースのインポート

今回は仮置きとして画像ファイルを使っています。

  • image1.png
  • image2.png
  • image3.png
f:id:qed805:20200408231942p:plain

image1.png

f:id:qed805:20200408232007p:plain

image2.png

f:id:qed805:20200408232018p:plain

image3.png

当然ですがこちらは仮で画像を当てはめているだけです。
実務で静的なデータをこんな感じでプロジェクトに置いていませんのでご注意ください。

これらの画像を assets/images/ のディレクトリに配置させます。

画像をインポートした後は pubspec.yaml ファイルに画像を使うための宣言を行います。

pubspec.yaml

flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  assets:
    - assets/images/image1.png
    - assets/images/image2.png
    - assets/images/image3.png

これでこれらの画像をソースコードで使えるようになりました。

ソースコードについて

それでは実際のソースコードを書いていきます。
まずは今回新しく作成した2つのファイルについてです。

second_section_header_item.dart

import 'package:flutter/material.dart';

class SecondSectionHeaderItem extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: <Widget>[
        Text(
          'くつろぎのひと時を',
          style: TextStyle(
              fontSize: 17, fontWeight: FontWeight.bold),
        ),
        FlatButton(
          onPressed: () {},
          child: Text(
            'すべて見る',
            style: TextStyle(fontSize: 10, color: Colors.pink),
          ),
        )
      ],
    );
  }
}

Row ウィジェットを使って横並びしました。
乗せているウィジェットはそれっぽいデザインを実現させているだけなので設計としてはいいのかは分かりません汗。

次にコンテンツ部分のコードです。

second_section_item.dart

import 'package:flutter/material.dart';
import 'package:apple_music_clone/utils/constants.dart';

class SecondSectionItem extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 220,
      child: ListView(
        scrollDirection: Axis.horizontal,
        children: <Widget>[
          _contentItem('assets/images/image2.png'),
          _contentItem('assets/images/image3.png'),
          _contentItem('assets/images/image2.png'),
          _contentItem('assets/images/image3.png'),
          _contentItem('assets/images/image2.png'),
          _contentItem('assets/images/image3.png'),
        ],
      ),
    );
  }

  Widget _contentItem(String imageString) {
    return Padding(
      padding: const EdgeInsets.only(right: 10.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Container(
            width: Constants().thumbnailImageSize,
            height: Constants().thumbnailImageSize,
            child: Image.asset(imageString),
          ),
          Text('イージー ヒッツ'),
          Text('Apple Music')
        ],
      ),
    );
  }
}

ここまで長くなると解説するだけでもとても大変です。
上級者からは見てのおわかりだと思いますが、命名規則は全然考慮していません。
レイアウト部分とデザイン部分を別々のファイルで管理したほうがいいのか、
同じファイルで管理したほうがいいのか、これらはまだ理解していない状態で組んでいます。

そして、最後に柱になる ContentSliverList のソースコードです。

content_sliver_list.dart

import 'package:apple_music_clone/utils/hex_color.dart';
import 'package:apple_music_clone/widgets/second_section_header_item.dart';
import 'package:apple_music_clone/widgets/second_section_item.dart';
import 'package:apple_music_clone/widgets/top_section_column_item.dart';
import 'package:flutter/material.dart';

enum Section {
  Top,
  Relax,
  ActivityMood,
  ShortMovie,
  Daily,
  Update,
  Attention,
  New,
  Now,
  Others,
  Favorite,
  Must,
  BestInterview,
  ComingSoon
}

class ContentSliverList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SliverList(
      delegate: SliverChildBuilderDelegate(
        (BuildContext context, int index) {
          if (index == Section.Top.index) {
            return _buildTopSectionPageView(context, 4);
          } else if (index == Section.Relax.index + 1) {
            return Padding(
              padding: const EdgeInsets.only(left: 5.0, right: 5.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  SecondSectionHeaderItem(),
                  SecondSectionItem(),
                ],
              ),
            );
          } else if (index % 2 == 0) {
            /// サンプル用のウィジェット
            return _buildSamplePageWidget(context, 4);
          } else {
            /// セパレーター
            return Padding(
              padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
              child: Divider(
                color: Colors.black,
              ),
            );
          }
        },
        childCount: 20,
      ),
    );
  }

  Widget _buildTopSectionPageView(BuildContext context, int itemCount) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        SizedBox(
          height: 300.0,
          child: PageView.builder(
            itemCount: itemCount,
            controller: PageController(viewportFraction: 0.9),
            itemBuilder: (BuildContext context, int itemIndex) {
              return _buildTopSectionItem();
            },
          ),
        )
      ],
    );
  }

  Widget _buildTopSectionItem() {
    final verticalPadding = const EdgeInsets.symmetric(vertical: 1.0);
    final horizontalPadding = const EdgeInsets.symmetric(horizontal: 5.0);
    return Padding(
      padding: horizontalPadding,
      child: TopSectionColumnItem(verticalPadding),
    );
  }
}

/// サンプル用ウィジェット
Widget _buildSamplePageWidget(BuildContext context, int itemCount) {
  return Column(
    mainAxisSize: MainAxisSize.min,
    children: <Widget>[
      SizedBox(
        height: 300.0,
        child: PageView.builder(
          itemCount: itemCount,
          controller: PageController(viewportFraction: 0.9),
          itemBuilder: (BuildContext context, int itemIndex) {
            return _buildSampleColumn(context, itemCount, itemIndex);
          },
        ),
      )
    ],
  );
}

Widget _buildSampleColumn(
    BuildContext context, int carouselIndex, int itemIndex) {
  final padding = const EdgeInsets.only(top: 1.0, bottom: 1.0);
  return Padding(
    padding: EdgeInsets.symmetric(horizontal: 5.0),
    child: Column(
      children: <Widget>[
        Align(
          alignment: Alignment.centerLeft,
          child: Padding(
            padding: padding,
            child: Text(
              'ニューアルバム',
              style: TextStyle(
                  color: HexColor('#C24B65'),
                  fontSize: 10,
                  fontWeight: FontWeight.w700),
            ),
          ),
        ),
        Align(
          alignment: Alignment.centerLeft,
          child: Padding(
            padding: padding,
            child: Text('Sparkle',
                style: TextStyle(
                    color: HexColor('#030303'),
                    fontSize: 15,
                    fontWeight: FontWeight.w500)),
          ),
        ),
        Align(
          alignment: Alignment.centerLeft,
          child: Padding(
            padding: padding,
            child: Text('iri',
                style: TextStyle(
                    color: HexColor('#89898B'),
                    fontSize: 15,
                    fontWeight: FontWeight.w500)),
          ),
        ),
        Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.all(Radius.circular(5.0)),
          ),
          child: Image.asset('assets/images/image1.png'),
        )
      ],
    ),
  );
}

とても長くなりました。

Apple Music アプリぐらいになるとソースコードで複雑さが分かります。
ただ Flutter の場合は適切なウィジェットは揃っている感じなので使うウィジェットさえ間違えなければ
作りたい UI / UX は実現できそうです。

これをビルドすると次のような画面が表示されます。

f:id:qed805:20200408233940p:plain

スクリーンショット

これでセクション2つ目のデザインが仕上がりました。
今のところは iOS と違って delegate やプロトコル的なものを使わずにやりたいことができています。

そろそろ Swift でいうところの Delegate や Notification 的な機能を使ってみたいですね。
本日はコードが長くなりましたのでこれで終わりにします。

ABOUT ME
tamappe
都内で働くiOSアプリエンジニアのTamappeです。 当ブログではモバイルアプリの開発手法について紹介しています。メインはiOS、サブでFlutter, Android も対応できます。 執筆・講演のご相談は tamapppe@gmail.com までお問い合わせください。