🥣

FlutterでMixinを使う

2023/01/26に公開

mixinとは何か?

https://dart.dev/guides/language/language-tour

公式を翻訳

クラスへの機能追加:Mixin

ミキシンは、クラスのコードを複数のクラス階層で再利用するための方法です。

ミキシンを使用するには、with キーワードに続けて 1 つ以上のミキシン名を使用します。次の例では、ミキシンを使用する 2 つのクラスを示しています。

class Musician extends Performer with Musical {
  // ···
}

class Maestro extends Person with Musical, Aggressive, Demented {
  Maestro(String maestroName) {
    name = maestroName;
    canConduct = true;
  }
}

ミキシンを実装するには、Object を継承し、コンストラクタを宣言していないクラスを作成します。ミキシンを通常のクラスとして使用したいのでなければ、class の代わりに mixin キーワードを使用します。たとえば、以下のようになります。

mixin Musical {
  bool canPlayPiano = false;
  bool canCompose = false;
  bool canConduct = false;

  void entertainMe() {
    if (canPlayPiano) {
      print('Playing piano');
    } else if (canConduct) {
      print('Waving hands');
    } else {
      print('Humming to self');
    }
  }
}

どんなことができるかと言いますと、クラスに機能を作って後から追加できるってことです。書き方が独特なので、慣れるまでわからないですね。
今回は、Flutterで使ってる人をあまり見かけないので、これは多分推奨されてないと思いますが、mixin使って、FireStoreを操作するコードを書いてみました。
最近、コードを書きすぎて、ファイルが増えすぎたので、プロジェクトごとGithubにpushせずに、コードだけ公開しました。
https://github.com/sakurakotubaki/MixinMovies

今回作成したアプリ

Androidのエミュレーターが動作が遅いのか動きがカクカクですけど、アプリの動作は確認できます。
参考までにどうぞ。
https://youtu.be/d9z9DGVU1tA

今回は、Mixinの設定ファイルをおくmixin_fileディレクトリと、データの追加・表示・削除をするファイルをおくmovie_pageディレクトリを作成してください。

Mixinの設定ファイルを作成

最近使ってみた、withConverterを今回は使用してみようと思います。
コードを書いてて気づいたのですけど、このファイルにwithConverterのコードを書いてないと、mixinの中で、importができませんでした🤔

mixin_file/movie_model.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class Movie {
  Movie({required this.title, required this.genre, required this.createdAt});

  Movie.fromJson(Map<String, Object?> json)
      : this(
          title: json['title']! as String, // Stringにcastする.
          genre: json['genre']! as String, // Stringにcastする.
          createdAt: json['createdAt']! as Timestamp, // Timestampにcastする.
        );

  final String title; // 映画のタイトル.
  final String genre; // 映画のジャンル.
  final Timestamp createdAt; // データが作成された時間.

  Map<String, Object?> toJson() {
    return {
      'title': title,
      'genre': genre,
      'createdAt': createdAt,
    };
  }
}

final moviesRef =
    FirebaseFirestore.instance.collection('movies').withConverter<Movie>(
          fromFirestore: (snapshot, _) => Movie.fromJson(snapshot.data()!),
          toFirestore: (movie, _) => movie.toJson(),
        );

withConverterを使用して、データの追加・表示・削除を行う機能をmixinに定義しておく。
使用するときは、mixinを継承しているDataStoreクラスから呼び出す。

mixin_file/movies_converter.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/cupertino.dart';
import 'package:timelineview_app/mixin_file/movie_model.dart';

mixin MoviesConverter {
  // Formで入力に使うコントローラー.
  final TextEditingController titleController = TextEditingController();
  final TextEditingController genreController = TextEditingController();
  // FireStoreの値を全て取得する.
  final moviesConverter =
      FirebaseFirestore.instance.collection('movies').withConverter<Movie>(
            fromFirestore: (snapshot, _) => Movie.fromJson(snapshot.data()!),
            toFirestore: (movie, _) => movie.toJson(),
          );
  // FireStoreにデータを追加する.
  Future<void> addMovies(String title, String genre) async {
    // 作成時刻を取得.
    final now = DateTime.now();

    // 映画情報を追加.
    await moviesConverter.add(
      Movie(title: title, genre: genre, createdAt: Timestamp.fromDate(now)),
    );
  }

  // FireStoreのデータを削除する.
  Future<void> removeMovies(dynamic document) async {
    // 映画情報を追加.
    await moviesConverter.doc(document).delete();
  }
}

DataStoreクラスを作成。アプリ側ではこのクラスを呼び出せば、mixinで追加したロジックも使用することができる。

mixin_file/data_store.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:timelineview_app/mixin_file/movie_model.dart';
import 'package:timelineview_app/mixin_file/movies_converter.dart';

// mixinを継承したクラス。アプリではこのクラスを呼び出す!
class DataStore with MoviesConverter {
  // falseにすると、昇順でデータを並び替える.
  final moviesOrderBy = FirebaseFirestore.instance
      .collection('movies')
      .orderBy('createdAt', descending: false)
      .withConverter<Movie>(
        fromFirestore: (snapshot, _) => Movie.fromJson(snapshot.data()!),
        toFirestore: (movie, _) => movie.toJson(),
      );
}

アプリを実行してみる

mixinでFirebase使ったことないので、どうなるのかためしにやってみたのですが、操作することはできるみたいです。
といっても普段は、RiverpodのStateNotire使うので、これは必要かなと考えますね。
Riverpod使わない人もいるので、何か参考にならないかなと思って作ってみました。

main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:timelineview_app/ui/movie_page/add_movie.dart';
import 'package:timelineview_app/ui/movie_page/orderby_page.dart';
import 'package:timelineview_app/ui/movie_page/stream_page.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SelectPage(),
    );
  }
}

class SelectPage extends StatelessWidget {
  const SelectPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ページを選択'),
      ),
      body: Center(
        child: Column(
          children: [
            SizedBox(height: 50),
            ElevatedButton(
                onPressed: () {
                  Navigator.push(context,
                      MaterialPageRoute(builder: (context) => StreamPage()));
                },
                child: Text('Stream')),
            SizedBox(height: 20),
            ElevatedButton(
                onPressed: () {
                  Navigator.push(context,
                      MaterialPageRoute(builder: (context) => OrderByPage()));
                },
                child: Text('OrderBy')),
            SizedBox(height: 20),
            ElevatedButton(
                onPressed: () {
                  Navigator.push(context,
                      MaterialPageRoute(builder: (context) => AddMovie()));
                },
                child: Text('AddMovie')),
          ],
        ),
      ),
    );
  }
}

データを追加するページ

movie_page/add_movie.dart
import 'package:flutter/material.dart';
import 'package:timelineview_app/service/mixin_file/data_store.dart';

class AddMovie extends StatelessWidget {
  const AddMovie({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    final dataStore = DataStore();
    final titleAdd = dataStore.titleController;
    final genreAdd = dataStore.genreController;

    return Scaffold(
      appBar: AppBar(
        title: Text('映画を追加する'),
      ),
      body: Center(
        child: Column(
          children: [
            TextField(
              controller: titleAdd,
              decoration: InputDecoration(
                hintText: "映画名を入力",
              ),
            ),
            TextField(
              controller: genreAdd,
              decoration: InputDecoration(
                hintText: "映画のジャンルを入力",
              ),
            ),
            SizedBox(height: 20),
            ElevatedButton(
                onPressed: () {
                  final addFunction =
                      dataStore.addMovies(titleAdd.text, genreAdd.text);
                },
                child: Text('映画を追加'))
          ],
        ),
      ),
    );
  }
}

Streamで表示するページ

movie_page/orderby_page.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:timelineview_app/service/mixin_file/data_store.dart';
import 'package:timelineview_app/service/mixin_file/movie_model.dart';

class StreamPage extends StatelessWidget {
  const StreamPage({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    final dataStore = DataStore().moviesConverter.snapshots();

    return Scaffold(
      appBar: AppBar(
        title: const Text('StreamPage'),
      ),
      body: StreamBuilder<QuerySnapshot<Movie>>(
        // クラスを型として使う.
        stream: dataStore, // withConverterのデータをstreamで流す.
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(
              child: Text(snapshot.error.toString()),
            );
          }

          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }

          final data = snapshot.requireData;

          return ListView.builder(
            // ListView.builderで画面に描画する.
            itemCount: data.size,
            itemBuilder: (context, index) {
              return ListTile(
                trailing: IconButton(
                  onPressed: () {
                    // ランダムに生成されたidを取得.
                    final document = data.docs[index].id;
                    // 削除するメソッドを実行する.
                    final removeData = DataStore().removeMovies(document);
                  },
                  icon: const Icon(Icons.delete),
                ),
                title: Text(
                    data.docs[index].data().title), // Movieクラスのtitleプロパティを使う.
                subtitle: Row(
                  children: [
                    Text(data.docs[index].data().genre),
                    const SizedBox(width: 20),
                    // .toStringだと変な文字になるので、.toDate()の後に
                    // .と入力してString型に変換するコードを選ぶ.
                    Text(data.docs[index]
                        .data()
                        .createdAt
                        .toDate()
                        .toIso8601String())
                  ],
                ), // Movieクラスのgenreプロパティを使う.
              );
            },
          );
        },
      ),
    );
  }
}

OrderByで並び替えて表示するページ

movie_page/stream_page.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:timelineview_app/service/mixin_file/data_store.dart';
import 'package:timelineview_app/service/mixin_file/movie_model.dart';

class OrderByPage extends StatelessWidget {
  const OrderByPage({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    final dataStore = DataStore().moviesOrderBy.snapshots();

    return Scaffold(
      appBar: AppBar(
        title: const Text('StreamPage'),
      ),
      body: StreamBuilder<QuerySnapshot<Movie>>(
        // クラスを型として使う.
        stream: dataStore, // withConverterのデータをstreamで流す.
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(
              child: Text(snapshot.error.toString()),
            );
          }

          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }

          final data = snapshot.requireData;

          return ListView.builder(
            // ListView.builderで画面に描画する.
            itemCount: data.size,
            itemBuilder: (context, index) {
              return ListTile(
                trailing: IconButton(
                  onPressed: () {
                    // ランダムに生成されたidを取得.
                    final document = data.docs[index].id;
                    // 削除するメソッドを実行する.
                    final removeData = DataStore().removeMovies(document);
                  },
                  icon: const Icon(Icons.delete),
                ),
                title: Text(
                    data.docs[index].data().title), // Movieクラスのtitleプロパティを使う.
                subtitle: Row(
                  children: [
                    Text(data.docs[index].data().genre),
                    const SizedBox(width: 20),
                    // .toStringだと変な文字になるので、.toDate()の後に
                    // .と入力してString型に変換するコードを選ぶ.
                    Text(data.docs[index]
                        .data()
                        .createdAt
                        .toDate()
                        .toIso8601String())
                  ],
                ), // Movieクラスのgenreプロパティを使う.
              );
            },
          );
        },
      ),
    );
  }
}

最後に

最近、Riverpodばかりでコード書いてたので、Riverpod使わないコードが書けないのに気づきました😅
お恥ずかしい💦
自作のクラス使ったり、関数作って、FireStoreのデータを取得するのもやってみたいですね。

Discussion