公開日:2022.03.30

Flutterで Windows 常駐アプリを作ってみた

テクログwindows

はじめに

こんにちは!
のりさんです。

前回の記事からFlutterのWindowsデスクトップアプリをはじめましたが
今回は「FlutterでWindows常駐アプリ」を作ってみます。

環境

  • Windows10 Pro
  • Android Studio Bumblebee 2021.1.1 Patch2
  • Flutter 2.10.3
  • Visual Studio 2019

アプリの内容

まず今回作るアプリの内容を考えてみます。
常駐する機能に加え、簡易タイマーと通知機能を実装することにしました。

アプリ起動時
 ・ウィンドウは存在するが非表示にしておく
 ・タスクトレイにアイコンを表示する

●タスクトレイアイコン
 ・左クリック時:ウィンドウを表示
 ・右クリック時:メニューを表示
   開く:ウィンドウを表示
   終了:アプリを終了する

ウィンドウ
 【簡易タイマー機能】
  ・スタートボタン押下でタイマー開始
  ・指定秒数経過したら通知する

利用パッケージ

常駐機能用

タスクトレイ(システムトレイ)常駐アプリを作るためにはパッケージを利用しないと難しそうなので
今回常駐機能用に2つのパッケージ( bitsdojo_window/ system_tray )を利用しています。

● bitsdojo_window
 ウィンドウのカスタマイズができるパッケージです。
 今回利用する部分としては
 ・ウィンドウ非表示状態でアプリを起動する
 ・ウィンドウ表示/非表示の切り替え
 ・タイトルバーや閉じるボタン等はウィジェットで作成
  ⇒常駐アプリのため 閉じるボタン押下時にはアプリ終了ではなくウィンドウ非表示にしたいところ

● system_tray
 タスクトレイを利用できるパッケージです。
 ・起動時にタスクトレイにアイコンを表示
 ・アイコンをクリックしてメニューを表示

その他

● flutter_riverpod
 状態管理用のパッケージです。
 アプリの状態管理に使っています。

● win_toast
 通知機能のパッケージです。
 今回はカウントダウンタイマーの通知用に使います。

● neon_circular_timer
 タイマー機能のパッケージです。

では上記のパッケージを使いながら実装してみます。

完成アプリ

完成したアプリは以下のようになりました!

アプリ起動時

まずアプリ起動時はウィンドウが表示されず、タスクトレイにアイコンが表示されました。
アイコンで右クリックするとメニューが表示されますね。

ウィンドウ表示

タスクトレイのアイコンを左クリックまたは右クリックメニューの開くから
簡易タイマーアプリのウィンドウが表示できました。

タイトルバーはWidgetで作られており
×ボタンを押すとウィンドウが非表示になる仕組みになっています。
非表示後はタスクトレイアイコンからいつでも表示可能です。

通知

タイマー開始ボタンを押して設定時間が経過した際に通知されるようにしました。
通知ウィンドウを左クリックすると簡易タイマーウィンドウが最前面に表示されます。
(ウィンドウ非表示状態でも表示されるようにしています)

ソースコード

準備

まずアプリ起動時のウィンドウ非表示設定です。
bitsdojo_windowの初期設定でC++ソースに以下を追記します。
(これを書くことで起動時のウィンドウが非表示状態でかつ、タイトルバーも非表示になるようです)

// #includeが書かれている箇所
...
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
auto bdw = bitsdojo_window_configure(BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP);
...

次に今回Flutterで利用するパッケージとタスクトレイ用アイコンを設定していきます。

dependencies:
  flutter:
    sdk: flutter
  system_tray: ^0.1.0
  bitsdojo_window: ^0.1.1+1
  flutter_riverpod: ^1.0.3
  win_toast: ^0.0.2
  neon_circular_timer: ^0.0.7

flutter:
  assets:
    - assets/app_icon.ico

main.dart

コンストラクタでタスクトレイの設定を渡したかったので
WindowsSystemTrayApp クラスを実装してみました。

またウィンドウタイトルバー部分のコードが長くなったため、クラスにまとめています。
WindowsDesktopUiBaseWidget:ウィンドウのベースウィジェット
WindowsDesktopUiColors:ウィンドウのカラー設定

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:system_tray/system_tray.dart';
import 'package:win_toast/win_toast.dart';
import 'package:flutter_windows_app/windows/common/provider.dart';
import 'package:flutter_windows_app/windows/common/desktop_ui_base.dart';
import 'package:flutter_windows_app/windows/common/desktop_ui_colors.dart';
import 'package:flutter_windows_app/windows/common/system_tray_app.dart';
import 'package:flutter_windows_app/windows/view/count_down_timer.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp( ProviderScope( child: const MyApp() ) );
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return WindowsSystemTrayApp(
        init: () async {
          // 通知パッケージの初期化
          await WinToast.instance().initialize(
              appName: ref.watch(windowTitleProvider),
              productName: ref.watch(windowTitleProvider),
              companyName: ''
          );
        },
        title: ref.watch(systemTrayTitleProvider), // タスクトレイタイトル
        tooltip: ref.watch(systemTrayTitleProvider), // タスクトレイツールチップ
        iconPath: ref.watch(systemTrayIconPathProvider), // タスクトレイアイコンパス(assets)
        menuItems: [ // タスクトレイメニュー
          MenuItem(label: '開く', onClicked: ()=>_showViewCountDownTimer() ),
          MenuSeparator() ,
          MenuItem(label: '終了', onClicked: ()=>appWindow.close() ),
        ],
        leftMouseUp: (systemTray)=>_showViewCountDownTimer(), // タスクトレイアイコン左マウスアップ
        rightMouseUp: (systemTray)=>systemTray.popUpContextMenu(), // タスクトレイアイコン右マウスアップ
        child: MaterialApp(
            debugShowCheckedModeBanner: false,
            home: WindowsDesktopUiBaseWidget(
              iconPath: ref.watch(systemTrayIconPathProvider), // ウィンドウのアイコンパス
              title: ref.watch(windowTitleProvider), // ウィンドウのタイトル
              body: ViewCountDownTimer(),            // ウィンドウの内容
              colors: WindowsDesktopUiColors.defaultColors() // ウィンドウの色設定
            )
        )
    );
  }

  // 簡易タイマーウィンドウの表示
  void _showViewCountDownTimer(){
    appWindow.minSize = const Size(300, 390);
    appWindow.size = const Size(300, 390);
    appWindow.alignment = Alignment.center;
    appWindow.show();
  }

}

provider.dart

riverpod のプロバイダはこのファイルにまとめてみました。

import 'package:flutter_riverpod/flutter_riverpod.dart';

// プロバイダ
final windowTitleProvider = StateProvider((ref)=>'簡易タイマーアプリ'); // ウィンドウタイトル
final windowsIsMaximizedProvider = StateProvider((r)=>false);        // ウィンドウが最大化されているかどうか
final timerIsRuuningProvider = StateProvider((r)=>false);            // タイマーが実行されているかどうか
final timerDurationProvider = StateProvider((r)=>5);                 // タイマー設定秒数
final systemTrayIconPathProvider = StateProvider((ref)=>'assets/app_icon.ico');         // タスクトレイアイコンのパス
final systemTrayTitleProvider = StateProvider((ref)=>ref.watch(windowTitleProvider));   // タスクトレイタイトル

system_tray_app.dart

コンストラクタでタスクトレイの設定ができるようにしたウィジェットです。

import 'package:flutter/material.dart';
import 'package:system_tray/system_tray.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';

class WindowsSystemTrayApp extends StatefulWidget {
  final Widget child;
  final String iconPath;
  final List<MenuItemBase> menuItems;
  final String title;
  final String tooltip;
  final void Function()? init;
  final void Function(SystemTray systemTray)? leftMouseUp;
  final void Function(SystemTray systemTray)? leftMouseDown;
  final void Function(SystemTray systemTray)? rightMouseUp;
  final void Function(SystemTray systemTray)? rightMouseDown;
  const WindowsSystemTrayApp({
    required this.child,
    required this.title,
    required this.tooltip,
    required this.iconPath,
    this.init,
    this.leftMouseUp,
    this.leftMouseDown,
    this.rightMouseUp,
    this.rightMouseDown,
    this.menuItems = const <MenuItemBase>[],
    Key? key,
  }) : super(key: key);

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

class WindowsSystemTrayAppState extends State<WindowsSystemTrayApp> {
  final systemTray = SystemTray();
  @override
  void initState() {
    super.initState();
    doWhenWindowReady(() {
      // ウィンドウの準備ができたら常駐アイコンを設定する
      _initTaskTray();
      if (widget.init != null) {
        widget.init!();
      }
    });
  }

  Future<void> _initTaskTray() async {
    await systemTray.initSystemTray(title: widget.title, iconPath: widget.iconPath, toolTip: widget.tooltip);
    if (!widget.menuItems.isEmpty) {
      await systemTray.setContextMenu(widget.menuItems);
    }
    systemTray.registerSystemTrayEventHandler((eventName) {
      switch(eventName){
        case "leftMouseDown":
          if (widget.leftMouseDown != null) {
            widget.leftMouseDown!(systemTray);
          }
          break;
        case "leftMouseUp":
          if (widget.leftMouseUp != null) {
            widget.leftMouseUp!(systemTray);
          }
          break;
        case "rightMouseUp":
          if (widget.rightMouseUp != null) {
            widget.rightMouseUp!(systemTray);
          }
          break;
        case "rightMouseDown":
          if (widget.rightMouseDown != null) {
            widget.rightMouseDown!(systemTray);
          }
          break;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return widget.child;
  }

}

desktop_ui_base.dart

ウィンドウのタイトルバー部分の実装が長くなったので別ウィジェットにしました。
タイトルバーがウィジェットのため、ウィンドウの最大化・元のサイズに戻す部分にも手を入れています。

import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_windows_app/windows/common/provider.dart';
import 'desktop_ui_colors.dart';
import 'package:flutter/widgets.dart';

class WindowsDesktopUiBaseWidget extends ConsumerStatefulWidget {
  final String title;
  final Widget body;
  final WindowsDesktopUiColors colors;
  final String? iconPath;
  WindowsDesktopUiBaseWidget({
    required this.title,
    required this.body,
    required this.colors,
    this.iconPath,
    Key? key,
  }) : super(key: key);
  @override
  ConsumerState<ConsumerStatefulWidget> createState() => WindowsDesktopUiBaseWidgetState();
}

class WindowsDesktopUiBaseWidgetState extends ConsumerState<WindowsDesktopUiBaseWidget> {
  @override
  Widget build(BuildContext context) {
    return WindowBorder(
      color: const Color(0xFF805306),
      width: 1,
      child: Container(
        color: const Color(0xFFFFFFFF),
        child: Scaffold(
            body: Column(
              children: [
                _createWindowTitleBar(),
                widget.body,
              ],
            )),
      ),
    );
  }

  Widget _createWindowTitleBar() {
    return Consumer(builder: (context, ref, child) => WindowTitleBarBox(
      child: Container(
        decoration: widget.colors.titleBarDecoration,
        child: Row(
          children: [
            Expanded(
              child: MyMoveWindow(
                  child: Container(
                    child: Row(
                        children: [
                          if (widget.iconPath != null)
                            Padding(
                                padding: EdgeInsets.all(5),
                                child: Image.asset(widget.iconPath!, fit: BoxFit.contain)
                            ),
                          Text(widget.title)
                        ]
                    ),
                    alignment: Alignment.centerLeft,
                  )),
            ),
            Row(
              children: [
                MinimizeWindowButton(
                  colors: widget.colors.minimizeWindowButtonColors,
                  onPressed: () => appWindow.minimize(),
                ),
                Builder(builder: (_) {
                  if (ref.watch(windowsIsMaximizedProvider)) {
                    return MyRestoreWindowButton(
                        colors: widget.colors.maximizeWindowButtonColors,
                        onPressed: () {
                          appWindow.restore();
                          // 最大化状態を更新(アイコン表示切り替え用)
                          ref.read(windowsIsMaximizedProvider.state).update((state) => false);
                        }
                    );
                  }
                  return MaximizeWindowButton(
                      colors: widget.colors.maximizeWindowButtonColors,
                      onPressed: () {
                        appWindow.maximize();
                        // 最大化状態を更新(アイコン表示切り替え用)
                        ref.read(windowsIsMaximizedProvider.state).update((state) => true);
                      }
                  );
                }),
                CloseWindowButton(
                    colors: widget.colors.closeWindowButtonColors,
                    onPressed: () => appWindow.hide() // 常駐アプリのためクローズせずに非表示
                )
              ],
            )
          ],
        ),
      ),
    ));
  }
}

// 元のサイズに戻すボタン(最大化後)
class MyRestoreWindowButton extends WindowButton {
  MyRestoreWindowButton({Key? key, WindowButtonColors? colors, VoidCallback? onPressed, bool? animate}) : super(
      key: key,
      colors: colors,
      animate: animate ?? false,
      iconBuilder: (buttonContext) => RestoreIcon(color: buttonContext.iconColor),
      onPressed: onPressed ?? () => appWindow.maximizeOrRestore()
  );
}

// タイトルバーの移動用(ドラッグ&ドロップ)
class MyMoveWindow extends ConsumerWidget {
  final Widget child;
  MyMoveWindow({Key? key, required this.child}) : super(key: key);
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return GestureDetector(
      behavior: HitTestBehavior.translucent,
      onPanStart: (details) {
        if (!appWindow.isMaximized) {
          // 最大化されていない場合のみ移動できるように
          appWindow.startDragging();
        }
      },
      onDoubleTap: () {
        // タイトルバーダブルクリックで 最大化 または 元のサイズに戻す
        ref.read(windowsIsMaximizedProvider.state).update((state) => !appWindow.isMaximized);
        appWindow.maximizeOrRestore();
      },
      child: this.child,
    );
  }
}

desktop_ui_colors.dart

タイトルバーのコードが長いため、カラー設定についても別クラスにしてみました。
今回実装していませんが、設定画面等でカラー設定の切り替えができそうです。

import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class WindowsDesktopUiColors {

  late final Color titleBarTextColor;
  late final BoxDecoration titleBarDecoration;
  late final WindowButtonColors minimizeWindowButtonColors;
  late final WindowButtonColors  maximizeWindowButtonColors;
  late final WindowButtonColors  closeWindowButtonColors;

  WindowsDesktopUiColors({required this.titleBarTextColor, required this.titleBarDecoration, required this.minimizeWindowButtonColors, required this.maximizeWindowButtonColors, required this.closeWindowButtonColors});
  WindowsDesktopUiColors.defaultColors() {
    // タイトルバー
    this.titleBarDecoration = const BoxDecoration(
      color: Color.fromRGBO(242, 242, 242, 0),
    );
    this.titleBarTextColor = Colors.black;

    // 最小化ボタン
    this.minimizeWindowButtonColors = WindowButtonColors(
        mouseOver: const Color.fromARGB(255,218,218,218),
        mouseDown: const Color.fromARGB(255,218,218,218),
        iconNormal: const Color.fromARGB(255,0,0,0),
        iconMouseOver: const Color.fromARGB(255,0,0,0),
        iconMouseDown: const Color.fromARGB(255,0,0,0),
    );

    // 最大化ボタン
    this.maximizeWindowButtonColors = WindowButtonColors(
        mouseOver: const Color.fromARGB(255,218,218,218),
        mouseDown: const Color.fromARGB(255,218,218,218),
        iconNormal: const Color.fromARGB(255,0,0,0),
        iconMouseOver: const Color.fromARGB(255,0,0,0),
        iconMouseDown: const Color.fromARGB(255,0,0,0),
    );

    // 閉じるボタン
    this.closeWindowButtonColors = WindowButtonColors(
      mouseOver: const Color.fromARGB(255,232,17,35),
      mouseDown: const Color.fromARGB(255,232,17,35),
      iconNormal: const Color.fromARGB(255,0,0,0),
      iconMouseOver: const Color.fromARGB(255,255,255,255),
      iconMouseDown: const Color.fromARGB(255,255,255,255),
    );

  }
}

おわりに

今回はWindows常駐アプリをパッケージを使って実装してみました。

まだまだWindows向けのパッケージは少ない印象ですが
これからどんどん増えていくことに期待しております。

またWindowsアプリでは複数ウィンドウを開くアプリもあるので
マルチウィンドウ対応もできないか時間があるときに調べてみたいと思います。

それではまた!

この記事を書いた人

のりさん

入社年2014年

出身地東京都

業務内容開発

特技または趣味水泳・筋トレ・旅行

のりさんの記事一覧へ

テクログに関する記事一覧