Flutterを使ってアプリで見かけるUIを作ってみた

この記事は[Flutter #3 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/flutter-3)の22日目の記事です。

ここ1ヶ月ほど趣味でFlutterを触るようになり、Widgetの使い方など少しずつ分かってくるようになってきました。
そんなタイミングで、普段アプリを使っていて「こういったUIって割と見かけよな〜」と思ったものを、自分でも実装してみたので備忘録も兼ねてまとめてみました。

以降のコードは、手元に画像を準備して設置すればコピペで動作確認できます！

# 環境
```bash:動作環境
$ flutter --version
Flutter 1.20.0 • channel master • https://github.com/flutter/flutter.git
Framework • revision eb1a6efe16 (7 months ago) • 2020-05-30 21:58:02 -0400
Engine • revision 923687f0e7
Tools • Dart 2.9.0 (build 2.9.0-18.0.dev 6489a0c68d)
```

# いい感じのタブバー
スクロールに合わせてヘッダー部分の表示領域が変化します

### 動作
![sliverappbar.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107176/99c08a8c-4be4-72b3-c391-aaec8179f9b2.gif)

```bash:console
$tree

├── assets // 追加
│   └── header_image.png　// 追加
└── lib
│   └── main.dart
└── ...
```


### コード

<details><summary>折りたたみ内にサンプルコード</summary><div>

```dart:main.dart
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyAppPage(),
    );
  }
}

class MyAppPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: DefaultTabController(
          length: headerTabs.length,
          child: NestedScrollView(
            headerSliverBuilder:
                (BuildContext context, bool innerBoxIsScrolled) {
              return <Widget>[
                SliverAppBar(
                  floating: true,
                  pinned: false,
                  expandedHeight: 30,
                  flexibleSpace: FlexibleSpaceBar(
                    title: const Text("DEMO APP"),
                    centerTitle: true,
                  ),
                ),
                SliverPersistentHeader(
                  pinned: true,
                  delegate: _Delegate(
                    TabBar(
                      isScrollable: true,
                      labelColor: Colors.black,
                      unselectedLabelColor: Colors.grey,
                      tabs: headerTabs
                          .map(
                            (tab) => Tab(text: tab),
                          )
                          .toList(),
                    ),
                  ),
                ),
              ];
            },
            body: Body(),
          ),
        ),
      ),
    );
  }
}

List<String> headerTabs = [
  'サンプル1',
  'サンプル2',
  'サンプル3',
  'サンプル4',
  'サンプル5',
];

class Body extends StatelessWidget {
  const Body({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TabBarView(
      children: [
        Center(
          child: Text("サンプル1"),
        ),
        Center(
          child: Text("サンプル2"),
        ),
        Center(
          child: Text("サンプル3"),
        ),
        Center(
          child: Text("サンプル4"),
        ),
        Center(
          child: Text("サンプル5"),
        ),
      ],
    );
  }
}

class _Delegate extends SliverPersistentHeaderDelegate {
  const _Delegate(this.tabBar);

  final TabBar tabBar;

  @override
  double get minExtent => tabBar.preferredSize.height;

  @override
  double get maxExtent => tabBar.preferredSize.height;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return Container(child: tabBar);
  }

  @override
  bool shouldRebuild(_Delegate oldDelegate) {
    return tabBar != oldDelegate.tabBar;
  }
}
```

</div></details>

```yml:pubspec.yml
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/sample_image.png # 追加
```

# 記事ページ
記事をタップすると、別ページに遷移します

### 動作
![article.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107176/adc4c329-b862-5a66-f473-e8bf16133588.gif)

```bash:console
$tree

├── assets // 追加
│   └── sample_image.png　// 追加
└── lib
│   └── main.dart
└── ...
```

### コード

<details><summary>折りたたみ内にサンプルコード</summary><div>

```dart:main.dart
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyAppPage(),
    );
  }
}

class MyAppPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("よく見る記事1"),
      ),
      body: GridView.builder(
        itemCount: articleList.length,
        padding: EdgeInsets.all(8.0),
        shrinkWrap: true,
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          mainAxisSpacing: 10.0,
          crossAxisSpacing: 10.0,
          childAspectRatio: 0.7,
        ),
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () => Navigator.of(context).push(
              MaterialPageRoute(
                builder: (context) {
                  // 記事をタップした際の遷移先のページ
                  return Scaffold(
                    appBar: AppBar(
                      title: Text("詳細ページ"),
                    ),
                    body: Center(
                      child: Text("詳細ページ"),
                    ),
                  );
                },
              ),
            ),
            child: Container(
              alignment: Alignment.center,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(4.0), // 角を丸くします
                color: Colors.white,
                boxShadow: [
                  BoxShadow(
                    color: Colors.grey,
                    offset: Offset(0, 3.0), // (x軸, y軸)
                    blurRadius: 5.0,
                  )
                ],
              ),
              child: Column(
                crossAxisAlignment:
                    CrossAxisAlignment.start, // Columnと交差する方向に対して、どの位置に合わせるかを指定
                mainAxisAlignment: MainAxisAlignment
                    .spaceBetween, // Columnと同じ方向に対して、どの位置に合わせるかを指定
                children: [
                  // 記事の画像
                  ClipRRect(
                    borderRadius: BorderRadius.vertical(
                      top: Radius.circular(4.0), // Containerの合わせて画像の上だけ角を丸くします
                    ),
                    child: Image.asset(
                      articleList[index].articleImagePath,
                      fit: BoxFit.cover,
                    ),
                  ),
                  // タイトル
                  Padding(
                    padding: EdgeInsets.all(8.0),
                    child: Text(
                      articleList[index].title,
                      maxLines: 3,
                      overflow: TextOverflow.ellipsis, // はみ出した文字を「...」表示にします
                      style: TextStyle(
                        fontSize: 14,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                  // 本文
                  Padding(
                    padding: EdgeInsets.symmetric(horizontal: 8.0),
                    child: Text(
                      articleList[index].content,
                      maxLines: 3,
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(
                        fontSize: 12,
                      ),
                    ),
                  ),
                  // 日付
                  Align(
                    alignment: Alignment.centerRight,
                    child: Padding(
                      padding: EdgeInsets.all(8.0),
                      child: Text(
                        articleList[index].createDate,
                        style: TextStyle(
                          fontSize: 10,
                          color: Colors.grey,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

class Article {
  final String title;
  final String createDate;
  final String articleImagePath;
  final String content;

  Article({
    this.title,
    this.createDate,
    this.articleImagePath,
    this.content,
  });
}

List<Article> articleList = [
  Article(
    title: sampleTitle,
    createDate: "2/27(月) 13:27",
    articleImagePath: "assets/sample_image.png",
    content: contentText,
  ),
  Article(
    title: "タイトル",
    createDate: "2/27(月) 13:27",
    articleImagePath: "assets/sample_image.png",
    content: contentText,
  ),
  Article(
    title: "タイトル",
    createDate: "2/27(月) 13:27",
    articleImagePath: "assets/sample_image.png",
    content: contentText,
  ),
  Article(
    title: "タイトル",
    createDate: "2/27(月) 13:27",
    articleImagePath: "assets/sample_image.png",
    content: contentText,
  ),
  Article(
    title: "タイトル",
    createDate: "2/27(月) 13:27",
    articleImagePath: "assets/sample_image.png",
    content: contentText,
  ),
  Article(
    title: "タイトル",
    createDate: "2/27(月) 13:27",
    articleImagePath: "assets/sample_image.png",
    content: contentText,
  ),
  Article(
    title: "タイトル",
    createDate: "2/27(月) 13:27",
    articleImagePath: "assets/sample_image.png",
    content: contentText,
  ),
];

// 折返し確認用の長文タイトル
String sampleTitle = "タイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトル";

// 折返し確認用の長文
String contentText =
    "親譲りの無鉄砲で小供の時から損ばかりしている。小学校に居る時分学校の二階から飛び降りて一週間ほど腰を抜かした事がある。なぜそんな無闇をしたと聞く人があるかも知れぬ。別段深い理由でもない。新築の二階から首を出していたら、同級生の一人が冗談に、いくら威張っても、そこから飛び降りる事は出来まい。弱虫やーい。と囃したからである。小使に負ぶさって帰って来た時、おやじが大きな眼をして二階ぐらいから飛び降りて腰を抜かす奴があるかと云ったから、この次は抜かさずに飛んで見せますと答えた。（青空文庫より）";
```

</div></details>

```yml:pubspec.yml
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/sample_image.png # 追加
```

# 記事ページ その2
記事をタップすると、外部サイトに遷移します
[こちらのパッケージを使用](https://pub.dev/packages/url_launcher)

### 動作
![article2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107176/8ae39676-0d45-245c-98ff-e61eeeb35df6.gif)

```bash:console
$tree

├── assets // 追加
│   └──sample_image2.png　// 追加
└── lib
│   └── main.dart
└── ...
```
### コード

<details><summary>折りたたみ内にサンプルコード</summary><div>

```dart:main.dart
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; // 追加

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyAppPage(),
    );
  }
}

class MyAppPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("よく見る記事2"),
      ),
      body: SingleChildScrollView(
        // スクロールできるようにするために、body以下をSingleChildScrollViewでラップする
        child: ListView.builder(
          shrinkWrap: true,
          physics: NeverScrollableScrollPhysics(),
          itemCount: presentModelList.length,
          itemBuilder: (context, index) {
            return GestureDetector(
              onTap: () {
                _launchURL(
                  presentModelList[index].articleUrl,
                );
              },
              child: Container(
                margin: EdgeInsets.only(
                  top: 10,
                  right: 10,
                  left: 10,
                ),
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(4.0),
                  color: Colors.white,
                  boxShadow: [
                    BoxShadow(
                      color: Colors.grey,
                      offset: Offset(0, 3.0), // (x軸, y軸)
                      blurRadius: 5.0,
                    )
                  ],
                ),
                child: Container(
                  child: Row(
                    children: [
                      ClipRRect(
                        // 画像の左半分の角を丸くします
                        borderRadius: BorderRadius.only(
                          topLeft: Radius.circular(
                            4.0,
                          ),
                          bottomLeft: Radius.circular(
                            4.0,
                          ),
                        ),
                        child: Image.asset(
                          presentModelList[index].thumbnailImagePath,
                          fit: BoxFit.cover,
                          width: 130,
                        ),
                      ),
                      Container(
                        width: MediaQuery.of(context).size.width -
                            150, // 画面横幅から画像などの横幅を引く
                        padding: EdgeInsets.all(8.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            // タイトル
                            Padding(
                              padding: EdgeInsets.only(bottom: 5),
                              child: Text(
                                presentModelList[index].title,
                                overflow: TextOverflow.ellipsis,
                                style: TextStyle(
                                  fontWeight: FontWeight.bold,
                                  fontSize: 15,
                                ),
                              ),
                            ),
                            // 本文
                            Text(
                              presentModelList[index].content,
                              maxLines: 2,
                              overflow: TextOverflow.ellipsis,
                              style: TextStyle(
                                fontWeight: FontWeight.bold,
                                fontSize: 12,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            );
          },
        ),
      ),
    );
  }

  _launchURL(String url) async {
    if (await canLaunch(url)) {
      await launch(
        url,
        forceSafariVC: true,
        forceWebView: true,
      );
    } else {
      throw 'Could not launch $url';
    }
  }
}

class PresentModel {
  final String title;
  final String content;
  final String thumbnailImagePath;
  final String articleUrl;

  PresentModel({
    this.title,
    this.content,
    this.thumbnailImagePath,
    this.articleUrl,
  });
}

List<PresentModel> presentModelList = [
  PresentModel(
    title: "テスト",
    content: "サンプル1",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: "サンプル2",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: "サンプル3",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: "サンプル4",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: "サンプル5",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: "サンプル6",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: "サンプル7",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: "サンプル8",
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: sampleTitle,
    content: contentText,
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
  PresentModel(
    title: "テスト",
    content: contentText,
    thumbnailImagePath: "assets/sample_image2.png",
    articleUrl: "https://google.com",
  ),
];

// 折返し確認用の長文タイトル
String sampleTitle = "タイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトルタイトル";

// 折返し確認用の長文
String contentText =
    "親譲りの無鉄砲で小供の時から損ばかりしている。小学校に居る時分学校の二階から飛び降りて一週間ほど腰を抜かした事がある。なぜそんな無闇をしたと聞く人があるかも知れぬ。別段深い理由でもない。新築の二階から首を出していたら、同級生の一人が冗談に、いくら威張っても、そこから飛び降りる事は出来まい。弱虫やーい。と囃したからである。小使に負ぶさって帰って来た時、おやじが大きな眼をして二階ぐらいから飛び降りて腰を抜かす奴があるかと云ったから、この次は抜かさずに飛んで見せますと答えた。（青空文庫より）";
```

</div></details>

```yml:pubspec.yml
dependencies:
  flutter:
    sdk: flutter
  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.3
  url_launcher: ^5.7.2 # 追加

  assets:
    - assets/sample_image2.png # 追加
```


## 週間天気
そのまま、週間天気です

### 見た目
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107176/9824f8b1-6cce-b230-72de-698e2df2d128.png" width="320">


```bash:console
$tree

assets　// 追加
└── weather　// 追加
│   ├── cloudy.png　// 追加
│   ├── rainy.png　// 追加
│   └── sunny.png　// 追加
└── lib
│   └── main.dart
└── ...
```

### コード

<details><summary>折りたたみ内にサンプルコード</summary><div>

```dart:main.dart
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyAppPage(),
    );
  }
}

class MyAppPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("天気"),
      ),
      body: Container(
        padding: EdgeInsets.all(8),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              "週間予報",
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
            ),
            Container(
              padding: EdgeInsets.only(top: 2),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: List.generate(
                  weeklyForecastList.length,
                  (index) => Container(
                    padding: EdgeInsets.symmetric(horizontal: 4),
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Padding(
                          padding: EdgeInsets.only(
                            bottom: 4,
                          ),
                          child: Text(
                            weeklyForecastList[index].datetime,
                            style: TextStyle(
                              fontSize: 11,
                              color: checkWeekend(
                                weeklyForecastList[index].datetime,
                              ),
                            ),
                          ),
                        ),
                        Padding(
                          padding: EdgeInsets.only(
                            bottom: 4,
                          ),
                          child: Image.asset(
                            weeklyForecastList[index].weatherIconPath,
                            width: 30,
                          ),
                        ),
                        Padding(
                          padding: EdgeInsets.only(
                            bottom: 4,
                          ),
                          child: Text(
                            weeklyForecastList[index]
                                .highestTemperature
                                .toString(),
                            style: TextStyle(
                              fontSize: 12,
                              color: Colors.red[400],
                            ),
                          ),
                        ),
                        Padding(
                          padding: EdgeInsets.only(
                            bottom: 4,
                          ),
                          child: Text(
                            weeklyForecastList[index]
                                .lowestTemperature
                                .toString(),
                            style: TextStyle(
                              fontSize: 12,
                              color: Colors.blue,
                            ),
                          ),
                        ),
                        Text(
                          weeklyForecastList[index].rainyPercent.toString() +
                              "%",
                          style: TextStyle(
                            fontSize: 12,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Color checkWeekend(String datetime) {
    if (datetime.contains("土")) {
      return Colors.blue;
    } else if (datetime.contains("(日)")) {
      return Colors.red[400];
    }
    return Colors.black;
  }
}

class Weather {
  final String weatherIconPath;
  final String datetime;
  final int highestTemperature;
  final int lowestTemperature;
  final int rainyPercent;

  Weather({
    this.weatherIconPath,
    this.datetime,
    this.highestTemperature,
    this.lowestTemperature,
    this.rainyPercent,
  });
}

List<Weather> weeklyForecastList = [
  Weather(
    weatherIconPath: kWeatherSunny,
    datetime: "12日(火)",
    highestTemperature: 30,
    lowestTemperature: 21,
    rainyPercent: 30,
  ),
  Weather(
    weatherIconPath: kWeatherCloudy,
    datetime: "13日(水)",
    highestTemperature: 31,
    lowestTemperature: 19,
    rainyPercent: 40,
  ),
  Weather(
    weatherIconPath: kWeatherCloudy,
    datetime: "14日(木)",
    highestTemperature: 30,
    lowestTemperature: 23,
    rainyPercent: 40,
  ),
  Weather(
    weatherIconPath: kWeatherRainy,
    datetime: "15日(金)",
    highestTemperature: 31,
    lowestTemperature: 21,
    rainyPercent: 90,
  ),
  Weather(
    weatherIconPath: kWeatherSunny,
    datetime: "16日(土)",
    highestTemperature: 30,
    lowestTemperature: 20,
    rainyPercent: 10,
  ),
  Weather(
    weatherIconPath: kWeatherSunny,
    datetime: "17日(日)",
    highestTemperature: 29,
    lowestTemperature: 21,
    rainyPercent: 10,
  ),
];

const kWeatherSunny = "assets/weather/sunny.png";
const kWeatherCloudy = "assets/weather/cloudy.png";
const kWeatherRainy = "assets/weather/rainy.png";
```

</div></details>

```yml:pubspec.yml
  assets:
    - assets/weather/sunny.png # 追加
    - assets/weather/cloudy.png # 追加
    - assets/weather/rainy.png # 追加
```
## まとめ
試行錯誤しながら作った結果が以上になります。
モデルや共通しているコードは別ファイルに分けると可読性が上がるので、手元で再現する際は該当コードを分離してみると良いかもしれません！