Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Flutter:スマホからVRChatの表情をコントロールする 後編

注意!
この記事は前回の記事を読んでいることを前提に作られています。
まだ読んでいない方は下のリンクからどうぞ。

Flutter:スマホからVRChatの表情をコントロールする 前編

🔗目次

  1. まえがき
  2. 最終的なコード
  3. 実装
  4. 完成品
  5. あとがき

🔗まえがき

みなさんどうもこんにちは、株式会社ECN所属のFuseです。
前回はスマホからデータを送信しVRChatのアバターを制御することに成功しました。
ですが表情を覚えられない問題の解決には至っていません。なので今回は各アバターのジェスチャーごとの表情名をPalletというクラスで管理し
Hiveという軽量なキーバリューストアライブラリを使ってローカルに保存することで
パレットを永続化していつでも読み込めるようにしていこうと思います。
↑目次に戻る

🔗最終的なコード

Fuses-Garage/osc_facial_controller - GitHub
↑目次に戻る

🔗実装

それでは御託は抜きにしてさっそく実装していきます。

入力フォーム画面

まずは入力フォームを実装していきます。

lib/screens/pallet_edit/pallet_edit_screen.dart

import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:osc_facial_controller/type/pallet.dart';

class PalletEditScreen extends StatefulWidget {
  const PalletEditScreen({super.key, this.palletkey});
  final String? palletkey;
  @override
  State<PalletEditScreen> createState() => _PalletEditScreenState();
}

class _PalletEditScreenState extends State<PalletEditScreen> {
  final _formKey = GlobalKey<FormState>();
  final _fieldKeyName = GlobalKey<FormFieldState>();
  final _fieldKeyL0 = GlobalKey<FormFieldState>();
  final _fieldKeyL1 = GlobalKey<FormFieldState>();
  final _fieldKeyL2 = GlobalKey<FormFieldState>();
  final _fieldKeyL3 = GlobalKey<FormFieldState>();
  final _fieldKeyL4 = GlobalKey<FormFieldState>();
  final _fieldKeyL5 = GlobalKey<FormFieldState>();
  final _fieldKeyL6 = GlobalKey<FormFieldState>();
  final _fieldKeyL7 = GlobalKey<FormFieldState>();
  final _fieldKeyR0 = GlobalKey<FormFieldState>();
  final _fieldKeyR1 = GlobalKey<FormFieldState>();
  final _fieldKeyR2 = GlobalKey<FormFieldState>();
  final _fieldKeyR3 = GlobalKey<FormFieldState>();
  final _fieldKeyR4 = GlobalKey<FormFieldState>();
  final _fieldKeyR5 = GlobalKey<FormFieldState>();
  final _fieldKeyR6 = GlobalKey<FormFieldState>();
  final _fieldKeyR7 = GlobalKey<FormFieldState>();
  Box<Pallet>? box;
  Pallet initialpallet = Pallet(
      leftidle: "Left Idle",
      leftfist: "Lfet Fist",
      leftopen: "Left Open",
      leftpoint: "Left Point",
      leftvictory: "Left Victory",
      leftrocknroll: "Left Rock'n'Roll",
      leftgun: "Left Gun",
      leftthumbsup: "Left Thumbsup",
      rightidle: "Right Idle",
      rightfist: "Right Fist",
      rightopen: "Right Open",
      rightpoint: "Right Point",
      rightvictory: "Right Victory",
      rightrocknroll: "Right Rock'n'Roll",
      rightgun: "RIght Gun",
      rightthumbsup: "Right Thumbsup");

  @override
  void initState() {
    super.initState();
    Hive.openBox<Pallet>("pallets").then((box) => {
          setState(() {
            this.box = box;
            var pallet = box.get(widget.palletkey);
            if (pallet != null) {
              _fieldKeyName.currentState!.didChange(widget.palletkey);
              _fieldKeyL0.currentState!.didChange(pallet.leftidle);
              _fieldKeyL1.currentState!.didChange(pallet.leftfist);
              _fieldKeyL2.currentState!.didChange(pallet.leftopen);
              _fieldKeyL3.currentState!.didChange(pallet.leftpoint);
              _fieldKeyL4.currentState!.didChange(pallet.leftvictory);
              _fieldKeyL5.currentState!.didChange(pallet.leftrocknroll);
              _fieldKeyL6.currentState!.didChange(pallet.leftgun);
              _fieldKeyL7.currentState!.didChange(pallet.leftthumbsup);
              _fieldKeyR0.currentState!.didChange(pallet.rightidle);
              _fieldKeyR1.currentState!.didChange(pallet.rightfist);
              _fieldKeyR2.currentState!.didChange(pallet.rightopen);
              _fieldKeyR3.currentState!.didChange(pallet.rightpoint);
              _fieldKeyR4.currentState!.didChange(pallet.rightvictory);
              _fieldKeyR5.currentState!.didChange(pallet.rightrocknroll);
              _fieldKeyR6.currentState!.didChange(pallet.rightgun);
              _fieldKeyR7.currentState!.didChange(pallet.rightthumbsup);
            } else {
              pallet = initialpallet;
            }
          })
        });
  }

  void returnPage() {
    Navigator.pop(context);
    Navigator.pop(context);
    Navigator.pushNamed(context, "/select");
  }

  void submit() async {
    if (_formKey.currentState!.validate()) {
      await box
          ?.put(
              _fieldKeyName.currentState?.value,
              Pallet(
                  leftidle: _fieldKeyL0.currentState?.value,
                  leftfist: _fieldKeyL1.currentState?.value,
                  leftopen: _fieldKeyL2.currentState?.value,
                  leftpoint: _fieldKeyL3.currentState?.value,
                  leftvictory: _fieldKeyL4.currentState?.value,
                  leftrocknroll: _fieldKeyL5.currentState?.value,
                  leftgun: _fieldKeyL6.currentState?.value,
                  leftthumbsup: _fieldKeyL7.currentState?.value,
                  rightidle: _fieldKeyR0.currentState?.value,
                  rightfist: _fieldKeyR1.currentState?.value,
                  rightopen: _fieldKeyR2.currentState?.value,
                  rightpoint: _fieldKeyR3.currentState?.value,
                  rightvictory: _fieldKeyR4.currentState?.value,
                  rightrocknroll: _fieldKeyR5.currentState?.value,
                  rightgun: _fieldKeyR6.currentState?.value,
                  rightthumbsup: _fieldKeyR7.currentState?.value))
          .then((v) => {returnPage()});
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text("表情コントローラー"),
      ),
      body: SingleChildScrollView(
          child: Form(
              key: _formKey,
              child: Column(
                children: [
                  TextFormField(
                    enabled: widget.palletkey == null,
                    key: _fieldKeyName,
                    decoration:
                        const InputDecoration(label: Text("Pallet Title")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL0,
                    decoration:
                        const InputDecoration(label: Text("Left Idle Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL1,
                    decoration:
                        const InputDecoration(label: Text("Left Fist Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL2,
                    decoration:
                        const InputDecoration(label: Text("Left Open Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL3,
                    decoration:
                        const InputDecoration(label: Text("Left Point Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL4,
                    decoration:
                        const InputDecoration(label: Text("Left Victory Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL5,
                    decoration: const InputDecoration(
                        label: Text("Left Rock'n'Roll Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL6,
                    decoration:
                        const InputDecoration(label: Text("Left Gun Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyL7,
                    decoration: const InputDecoration(
                        label: Text("Left Thumbsup Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR0,
                    decoration:
                        const InputDecoration(label: Text("Right Idle Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR1,
                    decoration:
                        const InputDecoration(label: Text("Right Fist Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR2,
                    decoration:
                        const InputDecoration(label: Text("Right Open Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR3,
                    decoration:
                        const InputDecoration(label: Text("Right Point Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR4,
                    decoration: const InputDecoration(
                        label: Text("Right Victory Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR5,
                    decoration: const InputDecoration(
                        label: Text("Right Rock'n'Roll Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR6,
                    decoration:
                        const InputDecoration(label: Text("Right Gun Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  TextFormField(
                    key: _fieldKeyR7,
                    decoration: const InputDecoration(
                        label: Text("Right Thumbsup Name")),
                    validator: (v) => v?.isNotEmpty ?? false ? null : "必須項目です",
                  ),
                  FilledButton(onPressed: submit, child: const Text("保存")),
                ],
              ))),
    );
  }
}

画面遷移時にオプショナルでキーを受け取ることで新規作成画面としても編集画面としても使えるようにしています。
flutterの標準のフォームは扱いが難しく、調べてもさまざまな記述方がありどれを採用するか悩んでいたのですが、
最終的に非同期での初期値の設定や一斉バリデート、値の取得を容易に行える
GlobalKey<FormState>GlobalKey<FormFieldState>を使った方式に落ち着きました。

パレット選択画面

登録したパレットの一覧が表示されるメインメニューで、ここからフォームやコントローラー画面に飛ぶ形になっています。

lib/screens/pallets_select/pallets_select_screen.dart

import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:osc_facial_controller/type/pallet.dart';

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

class _PalletsSelectScreenState extends State<PalletsSelectScreen> {
  List<dynamic> keys = [];
  Box<Pallet>? box;
  @override
  void initState() {
    super.initState();
    Hive.openBox<Pallet>("pallets").then((box) => {
          setState(() {
            keys = box.keys.toList();
            this.box = box;
          })
        });
  }

  Widget _getDropdownButton(String key, Box<Pallet>? box) {
    void onChanged(String? v) {
      var k = v ?? "";
      switch (k) {
        case "編集":
          Navigator.pushNamed(context, "/edit", arguments: key);
          break;
        case "削除":
          box?.delete(key).then((v) => {
                setState(() {
                  keys = this.box?.keys.toList() ?? [];
                })
              });

          break;
      }
    }

    return IconButton(
      icon: const Icon(Icons.more_vert),
      onPressed: () {
        showMenu<String>(
          context: context,
          position: const RelativeRect.fromLTRB(25.0, 25.0, 0.0, 0.0),
          items: <PopupMenuItem<String>>[
            const PopupMenuItem<String>(
                value: '編集',
                child: Row(
                  children: const [
                    Icon(Icons.edit),
                    Text('編集'),
                  ],
                )),
            const PopupMenuItem<String>(
                value: '削除',
                child: Row(
                  children: [
                    Icon(Icons.delete),
                    Text('削除'),
                  ],
                )),
          ],
        ).then((value) {
          onChanged(value);
        });
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text("表情コントローラー"),
      ),
      body: ListView.builder(
        itemCount: keys.length + 1, //1足す
        itemBuilder: (context, index) {
          if (index == keys.length) {
            //末尾は新規追加タイル
            return ListTile(
              title: const Text("パレットの新規作成"),
              trailing: const Icon(Icons.add),
              onTap: () =>
                  Navigator.pushNamed(context, "/edit", arguments: null),
            );
          }
          return ListTile(
            title: Text('${keys[index]}'),
            trailing: _getDropdownButton(keys[index], box),
            onTap: () => {
              Navigator.pushNamed(context, "/controller",
                  arguments: keys[index]),
            },
          );
        },
      ),
    );
  }
}

特に何の変哲もないリスト画面ですが、新規作成用のパネルを表示するためにitemCountを+1しています。

細かい調整

ここからは既存の画面に手を加えていきます。

lib/screens/title/title_screen.dart

//~~~中略~~~
  void _changescreen(BuildContext context) {
    Navigator.of(context).pushNamed("/select"); //パレット選択画面に移動する
  }
//~~~中略~~~

遷移先をパレット選択画面に変更してます。

lib/screens/controller/controller_screen.dart

//~~~中略~~~
class ControllerScreen extends StatefulWidget {
  final String? palletkey;
  const ControllerScreen({super.key, this.palletkey});
  @override
  State<ControllerScreen> createState() => _ControllerScreenState();
}

class _ControllerScreenState extends State<ControllerScreen> {
  final int port = 9000;
  String _ip = "127.0.0.1";
  Pallet? pallet;
  @override
  void initState() {
    Hive.openBox<Pallet>("pallets").then((box) => {
          setState(() {
            pallet = box.get(widget.palletkey);
          })
        });
    super.initState();
  }

  void send(String dir, int index) {
    //OSCメッセージをPCに送る
    final destination = InternetAddress.tryParse(_ip); //IPアドレス
    if (destination != null) {
      //正常にパースできたなら
      final address = "/avatar/parameters/G$dir"; //パス
      final message = OSCMessage(address, arguments: [index]);

      RawDatagramSocket.bind(InternetAddress.anyIPv4, 0).then((socket) {
        final bytes = message.toBytes();
        socket.send(bytes, destination, port);
        socket.close();
      });
    }
  }
//~~~中略~~~
}

遷移時にパレットのキーを受け取れるように改修し、パレットを読み込む処理を追加しました。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:osc_facial_controller/screens/controller/controller_screen.dart';
import 'package:osc_facial_controller/screens/pallet_edit/pallet_edit_screen.dart';
import 'package:osc_facial_controller/screens/pallets_select/pallets_select_screen.dart';
import 'package:osc_facial_controller/type/pallet.dart';
import 'package:osc_facial_controller/screens/title/title_screen.dart';
import 'package:path_provider/path_provider.dart';
import 'package:hive/hive.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();
  Hive.init(dir.path);
  Hive.registerAdapter(PalletAdapter());
  runApp(const MyApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const TitleScreen(),
      routes: <String, WidgetBuilder>{
        '/title': (BuildContext context) => const TitleScreen(),
        '/select': (BuildContext context) => const PalletsSelectScreen(),
      },
      onGenerateRoute: (settings) {
        if (settings.name == '/edit') {
          return MaterialPageRoute(
            builder: (context) => PalletEditScreen(
              palletkey: settings.arguments as String?,
            ),
          );
        }
        if (settings.name == '/controller') {
          return MaterialPageRoute(
            builder: (context) => ControllerScreen(
              palletkey: settings.arguments as String?,
            ),
          );
        }
        return null;
      },
    );
  }
}

Hiveの初期化処理や新しい画面のルーティングなど、様々な処理が増えています。
中でもonGenerateRouteはルーティングの際に引数を受け取りそれを使って画面を生成できる優れものでした。
↑目次に戻る

🔗完成品

これだけで終わるのも味気ないので何枚かスクリーンショットを掲載します。

▲実際に記入したフォーム

▲実際のパレット選択画面

▲実際のコントローラー画面

↑目次に戻る

🔗あとがき

いかがでしたか?Dartは最初は初めて触る言語のため戸惑うことも多々ありましたが慣れてくるとすいすい書けるようになる楽しい言語でした。
次回、また別の記事でお会いしましょう。
↑目次に戻る


株式会社ECNはPHP、JavaScriptを中心にお客様のご要望に合わせたwebサービス、システム開発を承っております。
ビジネスの最初から最後までをサポートを行い
お客様のイメージに合わせたWebサービス、システム開発、デザインを行います。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。