🍃

シンプルなスマホUIを実装する(Flutter):その1

に公開

概要

マルチプラットフォーム開発フレームワークの1つであるFlutterを利用してスマホUI(スマホアプリ)を開発してみます。

マルチ開発フレームワークはReactNativeとFlutterが2大巨頭で、日本ではFlutter、海外ではReactNativeが優勢らしいです。当社はReactNativeを利用していますが、外部案件だとFlutterを利用することもあるので最低限の知識はつけておきましょう。

前提

  • 環境
    • macOS
    • Visual Studio Codeがインストールされている
      • かつFlutter機能拡張がインストールされており、flutter doctorコマンドで問題が無い状態
    • Android Studioがインストールされており、シミュレータが利用できる
    • Xcodeがインストールされており、シミュレーターが利用できる
  • 知識

準備

必要な雛形を生成する

  • VSCodeにてコマンドパレットを開き、Flutter: New Project を入力・選択します
  • Empty Applicationを選択
  • 適切な保存場所選択してください
    • こだわりがなければホームディレクトリを選んでおいてください。サポートの際にわかりやすいので
  • Project Nameを聞かれるので入力してください
    • ここではsimple-mobile-flutterとしています

動作確認

プロジェクトの雛形が問題無く生成され実行可能か一度動作確認しておきます。
以下の操作を行います。

  • 動作確認する環境を指定
    • VSCode右下にあるプラットフォーム選択メニューを選択し「Start iOS Simulator」を選択します
      • iPhoneのシミュレータが起動するはずです(標準ではmacOSになっている)
      • メニューがよくわからない人は手動でiPhoneシミュレータを起動しておいてもらってもいいです
  • アプリの起動(デバッグ開始)
    • VSCodeのメニューからRun -> Start Debuggingを選択します
    • Select Debuggerの選択メニューが開くので、Dart & Flutterを選択してください

シミュレータにHello World!と表示されればOKです。

雛形の構成を確認する

プロジェクトディレクトリの1階層目は以下のようになっているはずです。
いろいろ多いですが、実装に利用するのはlibディレクトリとpubspec.yamlくらいです。

  • libディレクトリ
    • 実装ファイル
  • pubspec.yaml
    • ライブラリ等の設定で利用
.
├── analysis_options.yaml
├── android
├── flutter_application_1.iml
├── ios
├── lib
├── linux
├── macos
├── pubspec.lock
├── pubspec.yaml
├── README.md
├── web
└── windows

では、libディレクトリの中身を見てみます。

.
└── main.dart

main.dartファイルが1つあるだけです。Flutterのブートはこのmain.dartが実行されるだけとなります。

追加のファイルを作成する

では、実装に利用するファイルを作成します。ディレクトリ等は特に作成せずmain.dartと同じ階層に以下のファイルを作成します。

  • bottomtab.dart
    • ボトムタブを含む全体を構成するファイル。これをmain.dartから呼び出す想定
  • home.dart
    • Home画面
  • contact.dart
    • Contact画面。Formを実装する想定
touch lib/bottomtab.dart lib/home.dart lib/contact.dart

必要なライブラリをインストールする

API連携で利用する通信用ライブラリ(http)を設定しておきます。
ライブラリの追加はプロジェクトフォルダ直下にあるpubspec.yamlを編集して行います。
以下を追加します。記述後、自動的にライブラリが読み込まれます。

pubspec.yaml
name: flutter_application_1
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
+   http: any
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

実装

では実装していきます。

基本構造を実装する

本アプリはHome画面とContact画面の2画面を持ち、また、その画面はボトムタブで切り替え可能としたいので、その基本構成から実装してみます。

先にタブで表示する対象となるHomeとContactから実装してみます。

lib/home.dart

ただHomeと表示するだけの実装です。

なお、コメントとして// dart format offを追加しています。
これはVSCodeのFlutter機能拡張フォーマッタが効くのを一時的に無効化するための記述なので、標準のフォーマッタを使いたい人は無理に記述する必要はありません。

ここでは可読性向上のために入れた改行が無効化されてしまうので入れています。

home.dart
import 'package:flutter/material.dart';

// dart format off

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text("Home"),
    );
  }
}

lib/contact.dart

現状ただContactと表示するだけの実装ですが、Contactは動的に値を保持・操作するので状態を管理可能なStatefulWidgetとして実装します。

あらためてhome.dartをみてもらうとStatelessWidgetを継承しているかと思います。

contact.dart
import 'package:flutter/material.dart';

// dart format off

class Contact extends StatefulWidget {
  const Contact({super.key});

  @override
  State<Contact> createState() => _ContactState();

}

class _ContactState extends State<Contact> {

  @override
  Widget build(BuildContext context) {
    // 全体をColumnでレイアウト
    return Center(
      child: Text("Contact"),
    );
  }
}

StatefulWidgetを利用するとき、

  • StatefulWidgetを定義
    • 実際にStateを担うクラスを紐づける
  • Stateを管理や表示を担うクラスを定義

という記述は慣用句的に出てくるので慣れておきましょう。

技術的にはStatefulWidgetがイミュータブル(変更不可)のため、状態を別クラスで管理するという仕組みに基づいています。

bottomtab.dart

では、BottomTabを実装してみます。
BottomTab自体状態を管理するのでStatefulWidgetになっており、実際の機能実装は_BottomTabStateが担います。

主な実装手順やポイントは以下の通りです。

  • _selectedTabでどの(何番目の)Tabが選択されたかを管理
  • _screens配列に表示予定の画面(Widget)を代入
  • _onItemTappedで_selectedTabへの代入値を切り替え
  • body: _screens[_selectedTab]で画面を表示
  • bottomNavigationBarで実際のボトムタブを設定
bottomtab.dart
import 'package:flutter/material.dart';

// 画面インポート
import './home.dart';
import './contact.dart';

// dart format off

class BottomTab extends StatefulWidget {
  const BottomTab({super.key});
  @override
  State<BottomTab> createState() => _BottomTabState();
}

class _BottomTabState extends State<BottomTab> {

  // どのタブが選択されているかを保持(indexは0から開始)
  int _selectedTab = 0;

  // Widgetの配列(切り替えに利用)
  final List<Widget> _screens = [Home(),Contact()];

  // Tabがタップされたときの動作を定義(後で代入)
  void _onItemTapped(int index){
    setState((){
      _selectedTab = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _screens[_selectedTab],
      bottomNavigationBar: BottomNavigationBar(
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"),
          BottomNavigationBarItem(icon: Icon(Icons.mail), label: "Contact")
        ],
        currentIndex: _selectedTab,
        onTap: _onItemTapped,
      )
    );
  }
}

main.dart

では、main.dartを編集して全体が連携して動作するようにします。
と言っても実装したBottomTabを読み込み表示するだけです。

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

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

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

  // dart format off
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: BottomTab(),
    );
  }
}

ここまで記述ができたら一度動作確認を行ってください。

各ファイルを実装する

では基本構造ができたので各ファイルを仕上げていきます。

lib/home.dart

Homeはスタティックな内容なので淡々と書いていきます。
全体をColumnで囲み、さらに各要素をContainerで囲み縦に配置していきます。

home.dart
import 'package:flutter/material.dart';

// dart format off

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

  @override
  Widget build(BuildContext context) {
    // 全体をColumnでレイアウト
    return Column(
      children: [
        
        //hero
        Container(
          color: Color(0xFFAAAAAA),
          width: double.infinity,
          height: 200,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(height: 40), // spacer
              Text("ヒーローエリア",
                style: TextStyle(color: Colors.white, fontSize: 18)),
              Text("ヒーロエリアのキャッチコピー",
                style: TextStyle(color: Colors.white))
            ],
          ),
        ),

        //services

        //service_a
        SizedBox(height: 20),
        Container(
          color: Color(0xFFAAAAAA),
          width: MediaQuery.of(context).size.width * 0.8,
          height: 100,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text("サービスA",
                style: TextStyle(color: Colors.white, fontSize: 16)),
              Text("サービスAの説明",
                style: TextStyle(color: Colors.white)),
            ],
          ),
        ),

        //service_b
        SizedBox(height: 20),
        Container(
          color: Color(0xFFAAAAAA),
          width: MediaQuery.of(context).size.width * 0.8,
          height: 100,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text("サービスB",
                style: TextStyle(color: Colors.white, fontSize: 16)),
              Text("サービスBの説明",
                style: TextStyle(color: Colors.white)),
            ],
          ),
        ),

        //service_c
        SizedBox(height: 20),
        Container(
          color: Color(0xFFAAAAAA),
          width: MediaQuery.of(context).size.width * 0.8,
          height: 100,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text("サービスC",
                style: TextStyle(color: Colors.white, fontSize: 16)),
              Text("サービスCの説明",
                style: TextStyle(color: Colors.white)),
            ],
          ),
        ),
      ],
    );
  }
}

記述が終わったら動作確認してください。

サービスをコンポーネント化することも考えましたが、一旦ベタ書きで。

lib/contact.dart

Contactは少々複雑なので順を追って実装してみます

基本レイアウト

レイアウトの基本はHomeと同じですがFormはFormコンポーネントがあるのでそれを利用します。
入力項目はTextFormFieldで対応します。

contact.dart
import 'package:flutter/material.dart';

// dart format off

class Contact extends StatefulWidget {
  const Contact({super.key});

  @override
  State<Contact> createState() => _ContactState();

}

class _ContactState extends State<Contact> {
  
  @override
  Widget build(BuildContext context) {
    // 全体をColumnでレイアウト
    return Column(
      children: [

        //hero
        Container(
          color: Color(0xFFAAAAAA),
          width: double.infinity,
          height: 120,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(height: 40), // spacer
              Text("お問い合わせフォーム",
                style: TextStyle(color: Colors.white, fontSize: 18)),
              Text("お気軽にお問い合わせください",
                style: TextStyle(color: Colors.white))
            ],
          ),
        ),

        // form
        Padding(
          padding: EdgeInsets.all(30),
          child: Form(
            child: Column(
              children: [

                // title
                SizedBox(height: 10),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせタイトル",
                    border: OutlineInputBorder(),
                  ),
                ),

                // email
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "Email",
                    border: OutlineInputBorder(),
                  ),
                ),

                // message
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせ内容",
                    border: OutlineInputBorder(),
                  ),
                  minLines: 3,
                  maxLines: 5,
                  keyboardType: TextInputType.multiline,
                ),

                // button
                SizedBox(height: 30),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Color(0xFF333333),
                      foregroundColor: Color(0xFFFFFFFF),
                    ),
                    onPressed: (){},
                    child: Text("送信")),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

ここまでで一旦レイアウトに関する記述は完了です。
一旦動作確認をお願いします。

長くなるので記事を分けます。
その2に続く。

bluecodeテックブログ

Discussion