ほぼゼロから学ぶFlutterアプリ開発

# はじめに
アプリ開発のエンジニアがフロントエンド・バックエンド・インフラというように専業化されて、様々な開発言語やツールが生まれた現在において、バックエンドエンジニアが戦々恐々ながらにフロントエンドの開発に触れたらどこまでやれるのか、実践してみました。

# 技術選定および条件

今では数多くあるフロントエンド開発における術として、今回は`Flutter`を選択しました。

:::note info
**Flutterとは・・・**

Googleによって開発されたフリーかつオープンソースのUIのSDK。
単一のコードベースから、Android、iOS、Linux、macOS、Windows向けのクロスプラットフォームアプリケーションを開発することができる。

Flutterでは**Dart**言語を用いる。

DartはJavaScriptのような動的型付け言語でありながら、C#のような静的型付け言語の利点も兼ね備えたオープンソースのプログラミング言語。

:::

https://docs.flutter.dev/

また、今回は開発環境の構築から始めて、**7時間程度でどれくらいまで作ることができるのか**挑戦してみました。

# ゴール目標

何でもよかったのですが、これを考えるのに時間を使っても仕方がないですし、万が一にも制限時間内に完成してしまうことがないように、~~先日買い物で使った~~**CHAT●RAISEアプリのホーム画面的なデザインのもの**を作ってみることにしました。

CH●TERAISEアプリのホーム画面がどんな姿をしているかは、是非お手元のスマートフォンで確認してみてください（宣伝）

# 開発環境準備(～1.0時間経過)

ほぼゼロから開発を始めるため、まずは開発環境の構築を始めます。

OSはWindows、エディターはvscodeを使います
（ネットの情報を見るに、OSはMac、エディターはAndroid Studioを使ったほうがいいのかもしれません）。

Flutter開発環境の構築手順として、こちらの通りに進めました。

https://docs.flutter.dev/get-started/install/windows/desktop

細かい手順の記載は省略しますが、Android端末のエミュレータを動かすためにAndroid Studioを入れたり、設定変更のためにPC再起動したりと、結構時間がかかりましたね…。

# Flutterチュートリアルの実施(～2.0時間経過)

vscodeの使い方以外何も知らないので、時間は惜しいですがcodelabのチュートリアルを丁寧に進めました。

https://codelabs.developers.google.com/codelabs/flutter-codelab-first?hl=ja#0

「⑨次のステップ」のところまで進めました。雰囲気はつかめましたかね。

# ホーム画面の大枠の作成(～3.0時間経過)

プロジェクト新規作成後、後で整理するのも大変そうだったので、ある程度必要になりそうな機能にアタリをつけて構成を作ります。

### 新規プロジェクト作成

まずは新規プロジェクトの立ち上げです。

`Ctrl + Shift + P`を押してから、`Flutter: New Project`で`Application`を選択し、プロジェクトを作ります。

### ディレクトリ構成

ディレクトリ構成のうち、今回触れる部分だけを記載します。
新規作成するファイルはとりあえず空で用意だけします。

```shell
.
├── lib
│   └── main.dart           ★変更
│   └── footer.dart         ★新規作成
│   └── header.dart         ★新規作成
├── pubspec.yaml

And More...
```

### ヘッダの作成

当然奈良が難しいことは当然できないので、それっぽくなることだけ意識して作ります。

```dart:lib/header.dart
import 'package:flutter/material.dart';

class Header extends StatelessWidget implements PreferredSizeWidget {
  const Header({Key? keyx}) : super(key: keyx); // keyパラメータを追加

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);

  @override
  Widget build(BuildContext context) {
    return AppBar(
      title: const Text(
        'CHATER*ISE',
        // 'CHATERAISE(嘘)',
        style: TextStyle(color: Colors.brown), // 文字色を白に設定
      ),
      centerTitle: true, // タイトルを中央に配置
      backgroundColor: Colors.white, // 背景色を白に設定
    );
  }
}
```

ヘッダは状態を持たないので`StatelessWidget`を継承します。
中央に文字を表示したいので`centerTitle`を用いました。
backgroundColorで背景色を指定しています。

背景色に関してはthemeとして設定できたのかもしれませんが、時間との勝負もあったので、今回はできる範囲で作りました。

### フッタの作成

```dart:lib/footer.dart
import 'package:flutter/material.dart';

class Footer extends StatefulWidget {
  final Function(int) onItemTapped;

  const Footer({Key? keyx, required this.onItemTapped}) : super(key: keyx);


  @override
  FooterState createState() => FooterState();
}

class FooterState extends State<Footer> {
  int _selectedIndex = 0; // 現在選択されているアイテムのインデックス

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      unselectedItemColor: Colors.white,  // 未選択のアイテムの色を白に設定
      selectedItemColor: Colors.cyan,
      type: BottomNavigationBarType.fixed,
      backgroundColor: Colors.brown, // 背景色を茶色に設定
      items: const [
        BottomNavigationBarItem(
          icon: Icon(Icons.home_sharp),
          label: 'Home',
        ),
        BottomNavigationBarItem(
          icon: Icon(Icons.search_sharp),
          label: 'Home2',
        ),
        BottomNavigationBarItem(
          icon: Icon(Icons.account_box_sharp),
          label: 'member',
        ),
        BottomNavigationBarItem(
          icon: Icon(Icons.notifications_on_sharp),
          label: 'notice',
        ),
        BottomNavigationBarItem(
          icon: Icon(Icons.apps_sharp),
          label: 'menu',
        ),
      ],
      currentIndex: _selectedIndex, // 現在選択されているアイテムを指定
      onTap: (index) { // タップされたときの処理
        setState(() {
          _selectedIndex = index;
        });
        widget.onItemTapped(index); // 親ウィジェットに選択されたインデックスを通知
      },
    );
  }
}
```

フッタはアイコンの選択状態を保持するため`StatefulWidget`を継承しています（あってる？）。

画面下部に配置されるこれは、５つのボタンを横並びで持つため、`BottomNavigationBar`を使って並べました。

ボタンひとつひとつは`BottomNavigationBarItem`で定めます。`icon`と`label`でアイコン画像と文字を指定しています。

:::note info
`Icons`で指定できるアイコンにはかなりの数があります。vscodeのプラグインのおかげなのか、アイコンのサムネイルが表示されて便利でした。
:::


```dart:footer.dartより抜粋
      onTap: (index) { // タップされたときの処理
        setState(() {
          _selectedIndex = index;
        });
        widget.onItemTapped(index); // 親ウィジェットに選択されたインデックスを通知
      },
```

ボタンは順にindexを持つため、onTapによってタップ時にindexを受け取り、setStateでindexを保持するようにしています。
なお、 `widget.onItemTapped(index);`の`onItemTapped`はコールバック関数になります。

### メイン処理

flutterでは起動時にmain関数が呼び出されるため、そのときに動作させたい処理を書いていきます。

```dart:lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_application/header.dart';
import 'package:flutter_application/footer.dart';

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

class MyApp extends StatefulWidget {
  const MyApp({Key? keyx}) : super(key: keyx);

  @override
  MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> {
  int _selectedIndex = 0; // 現在選択されているページのインデックス

  void _onItemTapped(int index) { // タップされたときに呼び出される関数
    setState(() {
      _selectedIndex = index;
    });
  }
  @override
  Widget build(BuildContext context) {
    final pageOptions = [ // ページのウィジェットをリストで管理
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
    ];
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Chateraise Sample',

      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
        cardColor: Colors.white,
        scaffoldBackgroundColor: Colors.brown[200]
      ),
      home: Scaffold(
        appBar: const Header(),
        body: SingleChildScrollView( // SingleChildScrollViewを追加
          child: pageOptions[_selectedIndex],
        ),
        bottomNavigationBar: Footer(onItemTapped: _onItemTapped), // コールバック関数を渡す
      ),
    );
  }
}
```

それぞれ見ていきましょう。

`main.dart`では前述した`header.dart`と`footer.dart`を使いたいので、importします。

```dart:main.dartより抜粋
import 'package:flutter_application/header.dart';
import 'package:flutter_application/footer.dart';
```

相対パスで指定することもできますが、今回は絶対パスで指定しています。
なお、libの記載は不要です。~~なぜかは調べていません~~

```dart:main.dartより抜粋
  void _onItemTapped(int index) { // タップされたときに呼び出される関数
    setState(() {
      _selectedIndex = index;
    });
  }
```

`MyAppState`クラスに書いたこの処理は、前述のFooterクラスに渡すためのものです。

```dart
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Chateraise Sample',

      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
        cardColor: Colors.white,
        scaffoldBackgroundColor: Colors.brown[200]
      ),
      home: Scaffold(
        appBar: const Header(),
        body: SingleChildScrollView( // SingleChildScrollViewを追加
          child: pageOptions[_selectedIndex],
        ),
        bottomNavigationBar: Footer(onItemTapped: _onItemTapped), // コールバック関数を渡す
      ),
    );
```

戻り値`MaterialApp`にアプリの構成要素を設定しています。
`theme`に指定した値はアプリ全体に作用します
（各々で指定した場合はそちらで上書きされます）。

`home`では画面上部にあたる`appBar`、メインのコンテンツ部分となる`body`、画面下部にあたる`bottomNavigationBar`を設定します。
ここでHeader()やFooter()を設定しています。

```dart
...
    final pageOptions = [ // ページのウィジェットをリストで管理
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
    ];
...
    SingleChildScrollView( // SingleChildScrollViewを追加
      child: pageOptions[_selectedIndex],
    ),
```

`_selectedIndex`の値はFooterのボタンを押下したときのindexが設定されてます。
`pageOptions`リストにはbodyに設定したいコンテンツのwidgetを定義していますが、こちらはまだ作成していないので、`Placeholder()`を指定しておきます。

:::note info
Placeholder()はまだ実装していないWidgetがあるときに、その領域を×で表示するためのもので、codelabでも使われていました。
:::

### 画面確認

エラーがないことを確認してから、`Ctrl + Shift + P`を押してからの`Flutter: Select Device`で開発環境構築時に用意したAndroid端末のエミュレータを選択します。
次にプログラムを実行します。vscodeのメニューバーから`実行`->`デバッグの開始`で動かしてみます。

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801017/15d14dfc-f478-207e-abb4-5d504a869570.png">


おおむね想定通りの内容になりましたね。

Flutterにはホットリロード機能がありますので、エミュレータはこの先立ち上げたままでも大丈夫です。

# ホームコンテンツの作成およびリファクタリング(～7.0時間経過)

ここまでは準備で、ここからが本番です。

約4時間、トライアンドエラーを繰り返した結果、見た目だけはそれっぽいものができました。

### ディレクトリ構成


```shell
.
├── assets
│   └── 適当な画像ファイル    ★追加
├── lib
│   └── main.dart           ★変更
│   └── footer.dart
│   └── header.dart
│   └── home_contents.dart  ★新規作成
├── pubspec.yaml            ★変更

And More...
```

- ホームコンテンツを作るためのファイルを追加しています
- ホームコンテンツを呼び出すためにmain.dartに手を加えています
- 画像を読み込んだり外部パッケージを導入したのでpubspec.yamlも変更が加わります
- 画像ファイルはassets以下に格納

### ホームコンテンツの作成

素人なりに四苦八苦しながら作成したソースのため、かなりごちゃごちゃしています。
そこそこ長いので折りたたんでおきました。

<details><summary>home_contents.dartの中身（クリックでオープン）</summary>

```dart:lib/home_contents.dart
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:carousel_slider/carousel_slider.dart';

class HomeContents extends StatefulWidget {
  const HomeContents({Key? keyx}) : super(key: keyx);  // keyパラメータを追加

  @override
  HomeContentsState createState() => HomeContentsState();
}

class HomeContentsState extends State<HomeContents> {

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const SizedBox(height: 20.0),
        mainContents(),
        const SizedBox(height: 10.0),
        subContents(),
        const SizedBox(height: 20.0),
      ],
    );
  }

  Widget mainContents() {
    return Padding(
      padding: const EdgeInsets.only(top: 20.0, left: 20.0, right: 20.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.start,  // 上中央揃えに設定
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          const Card(
            child: ListTile(
              title: Center(child: Text('Cashipo会員証')),
              subtitle: Center(child: Text('DUMMY')),
            ),
          ),
          const Text("アプリ会員証の表示方法はこちら（テキスト）"),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,  // Cardを均等に配置
            children: <Widget>[
              Expanded(
                child: card1(Icons.phone_android_sharp, 'Web予約・店舗受け取り', '商品・受け取り店舗の指定'),
              ),
              Expanded(
                child: card1(Icons.store, 'マイページ', '会員向けメニュー'),
              ),
            ],
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              Expanded(
                child: card2(Icons.cake_sharp, '商品検索'),
              ),
              Expanded(
                child: card2(Icons.new_releases_sharp, '新商品'),
              ),
              Expanded(
                child: card2(Icons.calendar_month_sharp, '今月のイベント'),
              ),
            ],
          ),
          const Card(
            child: ListTile(
              title: Row(
                mainAxisAlignment: MainAxisAlignment.center, // 中央寄せに設定
                children: <Widget>[
                  Icon(Icons.store),
                  SizedBox(width: 8.0),
                  Text('オンラインストア'),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget subContents() {
    return Card(
      child: Column(
        children: <Widget>[
          const ListTile(
            title: Center(
              child: Text(
                'RECOMMENDATITON',
                style: TextStyle(fontSize: 18.0),
              ),
            ),
            subtitle: Center(
              child: Text(
                'おすすめ商品',
                style: TextStyle(fontSize: 10.0),
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(left: 10.0, right: 10.0),
            child: recommendationCard('https://www.google.co.jp/', 'ELL19613004_TP_V4.jpg'),
          ),
          Padding(
            padding: const EdgeInsets.only(left: 10.0, right: 10.0),
            child: recommendationCard('https://www.google.co.jp/', 'elly063A003_TP_V4.jpg'),
          ),
          CarouselSlider(
            items: recommendationCarousel1(),
            options: CarouselOptions(
              height: 200,
              initialPage: 0, //最初に表示するページ番号
              autoPlay: true, //自動スライド可否
              viewportFraction: 0.6,
              enableInfiniteScroll: true, //ループ可否
              autoPlayInterval: const Duration(seconds: 4),// 自動表示用設定
              autoPlayAnimationDuration: const Duration(milliseconds: 1000), // 自動表示用設定
            ),
          ),
          const ListTile(
            title: Center(
              child: Text(
                'PROMOTION',
                style: TextStyle(fontSize: 18.0),
              ),
            ),
            subtitle: Center(
              child: Text(
                'お得情報',
                style: TextStyle(fontSize: 10.0),
              ),
            ),
          ),
          CarouselSlider(
            items: carousel(1),
            options: CarouselOptions(
              height: 150,
              initialPage: 0, //最初に表示するページ番号
              autoPlay: false, //自動スライド可否
              viewportFraction: 0.5, //表示領域の割合
              enableInfiniteScroll: true, //ループ可否
            ),
          ),
          const ListTile(
            title: Center(
              child: Text(
                'ONLINE SHOP',
                style: TextStyle(fontSize: 18.0),
              ),
            ),
            subtitle: Center(
              child: Text(
                'オンラインショップ',
                style: TextStyle(fontSize: 10.0),
              ),
            ),
          ),
          CarouselSlider(
            items: carousel(2),
            options: CarouselOptions(
              height: 150,
              initialPage: 0, //最初に表示するページ番号
              autoPlay: false, //自動スライド可否
              viewportFraction: 0.5, //表示領域の割合
              enableInfiniteScroll: true, //ループ可否
            ),
          ),
          const ListTile(
            title: Center(
              child: Text(
                'ITEM SELECT',
                style: TextStyle(fontSize: 18.0),
              ),
            ),
            subtitle: Center(
              child: Text(
                '商品選びの参考に',
                style: TextStyle(fontSize: 10.0),
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(left: 10.0, right: 10.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: <Widget>[
                Expanded(
                  child: card3('https://www.google.co.jp/', 'aig-ai230706183-xl_TP_V4.jpg')
                ),
                Expanded(
                  child: card3('https://www.google.co.jp/', 'yudai_509s0044_TP_V4.jpg')
                ),
              ],
            ),
          ),
          const SizedBox(height: 20.0),
        ],
      ),
    );
  }

  // アイコンが左にあり、右にタイトルとサブタイトルがあるカード
  Widget card1(dynamic icon, dynamic title, dynamic subtitle) {
    return Card(
      child: ListTile(
        leading: Icon(icon),
        title: FittedBox(
          fit: BoxFit.scaleDown,
          child: Text(
            title,
            style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.bold),
          ),
        ),
        subtitle: FittedBox(
          fit: BoxFit.scaleDown,
          child: Text(
            subtitle,
            style: const TextStyle(fontSize: 12.0),
          ),
        ),
      ),
    );
  }

  // アイコンが上にあり、下にタイトルがあるカード
  Widget card2(dynamic icon, dynamic title) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: <Widget>[
            Icon(icon), // アイコンを追加
            Text(
              title,
              style: const TextStyle(fontSize: 14.0),
            ),
          ],
        ),
      ),
    );
  }

  // おすすめ商品の画像とリンク
  // たとえばAPIで受け取るようにしても良いのかもしれない
  Widget recommendationCard(dynamic link, dynamic imgFile) {
    return Card(
      elevation: 0,
      margin: const EdgeInsets.all(16),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
      ),
      child: InkWell(
        onTap: () {
          // url_launcherを使用
          // 画像をタップしたときのリンク先
          launchUrl(Uri.parse(link));
        },
        child: Stack(
          children: [
            Container(
              width: double.infinity,
              height: 260,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(8),
                image: DecorationImage(
                  image: AssetImage('assets/$imgFile'),
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  // その他のおすすめのカルーセル表示用
  // マップの情報はAPIで受け取るようにすれば、動的に増やすこともできる
  List<Widget> recommendationCarousel1() {
    Map<String, String> otherRecommendation = {
      'https://www.google.co.jp/' : 'FK+_50A0643_TP_V4.jpg',
      'https://www.yahoo.co.jp/' : 'iojima-PB074244162_TP_V4.jpg',
    };
    return otherRecommendation.entries.map((entry) {
      return Card(
        elevation: 0,
        margin: const EdgeInsets.all(16),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),
        ),
        child: InkWell(
          onTap: () {
            launchUrl(Uri.parse(entry.key));
          },
          child: Stack(
            children: [
              // 商品画像
              Container(
                width: double.infinity,
                height: 200,
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(8),
                  image: DecorationImage(
                    image: AssetImage('assets/${entry.value}'),
                    fit: BoxFit.cover,
                  ),
                ),
              ),
            ],
          ),
        ),
      );
    }).toList();
  }

  List<Widget> carousel(int mode) {
    Map<String, String> datas = {};
    switch(mode) {
      case 1:
        datas = {
          'https://www.google.co.jp/' : 'KAZ882_ajisai_TP_V4.jpg',
          'https://www.yahoo.co.jp/' : 'PK-PAUI8584_TP_V4.jpg',
        };
      case 2:
        datas = {
          'https://www.google.co.jp/' : 'red_sugarA1s8017_TP_V4.jpg',
          'https://www.yahoo.co.jp/' : 'yuta12-059_TP_V4.jpg',
        };
      default:
        throw Exception('Failed');
    }

    return datas.entries.map((entry) {
      return Card(
        elevation: 0,
        margin: const EdgeInsets.all(16),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),
        ),
        child: InkWell(
          onTap: () {
            launchUrl(Uri.parse(entry.key));
          },
          child: Stack(
            children: [
              // 商品画像
              Container(
                width: double.infinity,
                height: 150,
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(8),
                  image: DecorationImage(
                    image: AssetImage('assets/${entry.value}'),
                    fit: BoxFit.cover,
                  ),
                ),
              ),
            ],
          ),
        ),
      );
    }).toList();
  }

  // Cardが画像となり、タップするとリンク先へ飛ぶ
  Widget card3(String link, String imgFile) {
    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
      ),
      child: InkWell(
        onTap: () {
          launchUrl(Uri.parse(link));
        },
        child: Stack(
          children: [
            // 商品画像
            Container(
              width: double.infinity,
              height: 100,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(8),
                image: DecorationImage(
                  image: AssetImage('assets/$imgFile'),
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
```

</details>

長すぎてどこから解説したものか難しいところですが、まずはホームコンテンツの大枠から。

```dart:build()より抜粋
    return Column(
      children: [
        const SizedBox(height: 20.0),
        mainContents(),
        const SizedBox(height: 10.0),
        subContents(),
        const SizedBox(height: 20.0),
      ],
    );
```

`SizedBox`で上下に隙間を作っています。
Paddingでも良かったのかもしれませんが、わかりやすかったのでこうしています。

コンテンツはMainとSubに分けており、その間にも`SizedBox`で隙間を作っていますね。

以降ではメインとサブのコンテンツのポイントを説明していきます。

#### mainContentsの処理

```dart:mainContents()より抜粋
      child: Column(
        mainAxisAlignment: MainAxisAlignment.start,  // 上中央揃えに設定
        mainAxisSize: MainAxisSize.min,
...
      )
```

`Column`は要素を縦に並べるときに使います。`mainAxisAlignment`を用いることで表示位置を変えることができます。

```dart
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,  // Cardを均等に配置
            children: <Widget>[
...
            ],
          ),
```

`Row`は要素を横で並べるときに使います。
こちらも`mainAxisAlignment`を用いることで、位置関係を調整しています。

また、`Row`で並べる`Card`は同じフォーマットにしたかったので、`card1(アイコン, タイトル, サブタイル)`関数を作りました。
引数を変えてこれを呼ぶようにすることで、同じ構成の`Card`を作ることができます。

（同様の理由でCard2関数も作っています）


なお、参考にした本物では、これらCardをタップすることで別のコンテンツを表示することができますが、今回はそこまで実装を進めることができませんでした。


#### subContents処理

サブコンテンツ部分ではCardの中でCardを表示したり、カルーセルスライダーを追加したりしました。

順にみていきましょう。

##### 画像Cardを作る

```dart:recommendationCard()より抜粋
Card(
  elevation: 0,
  margin: const EdgeInsets.all(16),
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(8),
  ),
  child: InkWell(
    onTap: () {
      // url_launcherを使用
      // 画像をタップしたときのリンク先
      launchUrl(Uri.parse(link));
    },
    child: Stack(
      children: [
        Container(
          width: double.infinity,
          height: 260,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            image: DecorationImage(
              image: AssetImage('assets/$imgFile'),
              fit: BoxFit.cover,
            ),
          ),
        ),
      ],
    ),
  ),
);
```

こちらは`recommendationCard`関数の処理内容です。

ここでは`Card`のサイズに合わせた画像を表示し、タップすることで設定したリンク先に遷移することができるようにしています。

Footerのボタンの時にも出てきたOnTapの処理として、launchUrlを呼び出しています。

これはurl_launcherという外部パッケージのもので、引数に指定した遷移先に遷移できるようにするものです。

これを導入するために、コマンドラインから次のコマンドを打ち、`url_launcher`を導入しましょう。

```shell:コマンドラインより、プロジェクト直下で実行
$ flutter pub add url_launcher
```

https://pub.dev/packages/url_launcher

うまくいけば`pubspec.yaml`にurl_launcherが追加されているはずです。

```yaml:pubspec.yamlより抜粋
dependencies:
  flutter:
    sdk: flutter
...
  url_launcher: ^6.3.0
```

導入するだけでは使えないので、使いたいファイルでこれを`import`します。今回は`home_contents.dart`で使っているので、このファイルの先頭に以下を追記します。

```dart:home_contents.dartより抜粋
import 'package:url_launcher/url_launcher.dart';
```

これで使えるようになるはずです。

```dart:recommendationCard()より抜粋
    child: Stack(
      children: [
        Container(
          width: double.infinity,
          height: 260,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            image: DecorationImage(
              image: AssetImage('assets/$imgFile'),
              fit: BoxFit.cover,
            ),
          ),
        ),
      ],
    ),
```

ここでは`Card`のサイズに画像のサイズを合わせています。
(Stackは画像の上に文字を載せようとした名残です。無くても問題ないと思います）

画像の呼び出しは`AssetImage('画像パス')`になります。

ただし、画像の格納先は事前に`pubspec.yaml`で指定しておく必要があります。

```yaml:pubspec.yamlより抜粋
flutter:
...
  # To add assets to your application, add an assets section, like this:
  assets:
    - assets/
```

##### カルーセルスライダーを導入する

参考にした本物のアプリ側では、画像Cardの下に、自動で横にスライドする画像Cardが並んでいました。

これを実現するため、`carousel_slider`を使います。

https://pub.dev/packages/carousel_slider

`url_launcher`の時と同じように、`carousel_slider`をプロジェクトに追加します。

```shell:コマンドラインより、プロジェクト直下で実行
$ flutter pub add carousel_slider
```

`pubspec.yaml`に追記されていることも見てみましょう。

```yaml:pubspec.yamlより抜粋
dependencies:
  flutter:
...
  carousel_slider: ^4.2.1
```

`import`するのも忘れずに。

```dart:home_contents.dartより抜粋
import 'package:carousel_slider/carousel_slider.dart';
```

実際にカルーセルを設定している処理はこの部分。

```dart:subContents()より抜粋
     CarouselSlider(
       items: recommendationCarousel1(),
       options: CarouselOptions(
         height: 200,
         initialPage: 0, //最初に表示するページ番号
         autoPlay: true, //自動スライド可否
         viewportFraction: 0.6,
         enableInfiniteScroll: true, //ループ可否
         autoPlayInterval: const Duration(seconds: 4),// 自動表示用設定
         autoPlayAnimationDuration: const Duration(milliseconds: 1000), // 自動表示用設定
       ),
     ),
```

`items`にはCardのリストを設定しますが、Cardのフォーマットは画像とリンク先以外は同じものであるため、`recommendationCarousel1()`として関数化しました。
`
option`ではカルーセルの動作条件などを指定できます。
ここでは自動スライドさせたかったので、`autoPlay`をtrueにしています。また、その動作間隔なども設定しています。

`recommendationCarousel1()`関数で作成しているCardリストの内容は「画像Card」のものと同じですが、ここではカルーセル動作のためにCardがいくつも必要になるため、Mapを用いて複数のCardを作って返しています。

```dart:recommendationCarousel1()より抜粋
    Map<String, String> otherRecommendation = {
      'https://www.google.co.jp/' : 'FK+_50A0643_TP_V4.jpg',
      'https://www.yahoo.co.jp/' : 'iojima-PB074244162_TP_V4.jpg',
    };
    return otherRecommendation.entries.map((entry) {
      return Card(
...
            launchUrl(Uri.parse(entry.key));
...
                    image: AssetImage('assets/${entry.value}'),
...
      );
    }).toList();
```

map()を用いることでIterableな処理が可能になります。


以上がホームコンテンツのだいたいの実装内容になります。

### メイン処理の修正

最後に、ホームコンテンツを呼び出すためにmain.dartを直します。

```dart:lib/main.dart
...
import 'package:flutter_application/home_contents.dart';  // 追加
...
  @override
  Widget build(BuildContext context) {
    final pageOptions = [
      const HomeContents(), // 変更
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
      const Placeholder(),
    ];
...}
```

### 動作確認

`assets`ディレクトリ以下に`home_contents.dart`で指定している画像ファイルを配置してから、実際に動かしてみましょう。

次のような画面が出ているはずです。

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801017/e2adbd1c-a483-aab2-acbc-9fc33a8a78ba.png"><br>

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801017/5a7b34af-5c33-6d2d-3d30-2197deae769e.png"><br>

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801017/36fb84ed-f05b-ff7e-d5b1-ddd188bb035b.png"><br>

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801017/799acd33-1297-043f-3756-95662769d96d.png"><br>


画像では伝わりませんが、画像Cardをタップすることで画面遷移を確認することができます(仮なのでgoogleかyahooのトップに遷移します)。

また、carousel_sliderが正常に機能（自動で切り替わること、スワイプして切り替えられること）していることも確認できました。

# まとめ

思い立ったが吉日でflutterでのモバイルアプリ開発を行ってみましたが、やはり難しかったですね。

思ったようにwidgetが配置できなかったり、表示が崩れたり…（おそらく今も端末によっては崩れたり不格好になると思います）。

しかしながら、これを使いこなすことができればクロスプラットフォームに対応したアプリ開発ができるようになるはずなので、妄想は膨らみますね。

とはいえやっぱり、こういうアプリをこの先作ろうとするなら、yappliとか使うのが一番なんでしょうね…。

あるもの使っていけばいい（全部ぶち壊し）。

https://yapp.li/