Flutter on Desktopでウィンドウサイズを扱う話

# TL;DR

Windows向けで初期サイズ固定のアプリを作成する場合、ウィンドウサイズを指定してもフレームで指定より小さくなるので、
ウィンドウをフレームレスにして角丸、タイトルバーを別途実装したほうがいい。
macOSだと影響は受けないのでWindowsのみを対応すればいいかと。
使用パッケージは`window_manager`、マルチウィンドウを取り扱うのなら拡張したパッケージの`window_manager_plus`を使いたい。

# 環境

Flutter 3.24.4 • channel stable • https://github.com/flutter/flutter.git
Tools • Dart 3.5.4 • DevTools 2.37.3
fvm 3.2.1
Windows 11 Pro 23H2開発者モード
macOS Sonoma 14.7
VS Code 1.95.2

# Desktop向けFlutterの作成

作業ディレクトリを作成して、中からCLIで空のプロジェクト作成し起動します。

```shell-session
$ flutter create --platforms=windows,macos -e .
$ flutter run
```

この時点でデフォルトサイズのウィンドウが開き、スクリーンショットで確認するとWindowsだと`1266 * 713`になっていて、macOSだと`800 * 628`になっています。

![Windows-default](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/017325af-aa42-b1f4-9702-4b5aa6cc9dd1.png)

![macOS-default](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/041f97a3-488a-69f4-b71d-ac1732803624.png)

## ウィンドウ管理パッケージの導入

https://qiita.com/yoyo-misc/items/3c3dcf37f1e2ce988b5c

上記の記事を参考にしてひとまず`window_manager`を導入します。

```diff:pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
+ window_manager: ^0.4.3
```

## ウィンドウサイズ指定してアプリを起動

パッケージをインポートして、`main()`を以下のように変更します。
起動時のウィンドウオプションを指定して、ウィンドウのサイズと位置を明示するとします。

```dart:main.dart
import 'package:window_manager/window_manager.dart';

void main() async {
  // 初期化
  WidgetsFlutterBinding.ensureInitialized();

  // window_managerを初期化
  windowManager.ensureInitialized();
  // ウィンドウプロパティを指定
  WindowOptions windowOptions = const WindowOptions(
    size: Size(1280, 720),
    center: true,
  );

  // ウィンドウを表示
  windowManager.waitUntilReadyToShow(windowOptions, () async {
    await windowManager.show();
    await windowManager.focus();
  });

  runApp(const MainApp());
}
```

これで起動してウィンドウサイズを確認すると…

macOS `1280 * 720`
![macOS-with-option](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/d859c7cb-5a9d-947e-2daf-49e44b92fce2.png)

Windows `1266 * 713`
![Windows-with-option](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/b86eb03f-37a3-84e2-7f9c-3ba3830cfddb.png)

macOSだと問題ないのに、Windowsだと微妙に小さくなりました。
ちなみに`windows/runner/main.app`でウィンドウサイズをハードコードしても直りませんでした。

## 原因と解決案

問題の正体はWindowsアプリケーションのフレームにあり、これを消せば指定サイズの通りのウィンドウが生成されます。
`windowManager.waitUntilReadyToShow`の先頭に1行追加してウィンドウをフレームレスにします。


```diff:main.dart
  windowManager.waitUntilReadyToShow(windowOptions, () async {
+   await windowManager.setAsFrameless();
    await windowManager.show();
    await windowManager.focus();
  });
```

Windows `1280 * 720`
![Windows-with-option-frameless](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/0d8f773e-a0f8-f8a7-3e8c-f0f257346f95.png)

が、フレームを無くすと代わりに以下の問題があります。

- タイトルバーがなくなる
- ウィンドウコントロールボタンがなくなる
- Windowsの場合
  - 角が丸くなくなります。
  - ウィンドウをドラッグで動かせなくなる
  - ウィンドウのサイズ調整もできなくなる

これらを1つずつ解決します。

### ウィンドウを角丸化

`MainApp`クラスの`build`関数で`Scaffold`を`右クリック>リファクター...>Wrap with widget`で、
Scaffoldを子としてWidgetで囲います。

`widget`の部分は`ClipRRect`に書き換え、半径指定して角を丸くします。
Windowsならデフォルトアプリは7.5になってますので合わせると良いでしょう。
macOSの場合デフォルトですでに9.5の角丸が適用されており、これより小さく設定しても無視されます。

```dart:main.dart
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: ClipRRect(
        borderRadius: const BorderRadius.all(Radius.circular(7.5)),
        child: Scaffold(
          body: Center(
            child: Text('Hello World!'),
          ),
        ),
      ),
    );
  }
```

ただ、このままだと角の削られた部分が黒く表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/0dfa6042-3817-aa02-96fd-118ab2a3fa85.png)

解消するのに`main()`にある`windowOptions`で背景色を透明と指定します。

```diff:main.dart
  WindowOptions windowOptions = const WindowOptions(
    size: Size(1280, 720),
+    backgroundColor: Colors.transparent,
    center: true,
  );
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/a866970a-62e1-fca3-4c75-f087302c6c26.png)


### ウィンドウをドラッグで位置調整できるようにする

ドラッグしてウィンドウの位置を調整できる場所を決めます。
ここは例として、`Scaffold`の子を`Column`にして、上部にタイトルバーの仮置きとして青い`Container`を配置しました。

```dart:main.dart
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ClipRRect(
        borderRadius: const BorderRadius.all(Radius.circular(7.5)),
        child: Scaffold(
          body: Column(
            children: [
              Expanded(
                flex: 1,
                child: Container(
                  color: Colors.blue,
                ),
              ),
              const Expanded(
                flex: 19,
                child: Center(
                  child: Text('Hello World!'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/43ecaf9e-ab3d-83b2-208c-6af90b9e885a.png)

あとはこの青いContainerを`DragToMoveArea`を囲えば、青いContainer部をドラッグしてウィンドウを移動できるようになります。

```diff_dart:main.dart
              Expanded(
                flex: 1,
+               child: DragToMoveArea(
                  // タイトルバー代わりのContainer
                  child: Container(
                    color: Colors.blue,
                  ),
+               ),
              ),
```

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/01ebfca9-5014-7a5a-57ea-84e60bb8936c.gif)

#### ドラッグして移動する部分のダブルクリックを無効化

実装上、`DragToMoveArea`はドラッグして移動できるだけではなく、ダブルクリックしてウィンドウの最大化もできるようになっています。
もしこの動作が必要なければ、以下を参考にして実装をoverrideするか`GestureDetector`を使えば大丈夫かと思います。

```dart
class CustomDragToMoveArea extends DragToMoveArea {
  const CustomDragToMoveArea({super.key, required super.child});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      behavior: HitTestBehavior.translucent,
      onPanStart: (details) {
        windowManager.startDragging();
      },
      child: child,
    );
  }
}
```

### ウィンドウのサイズ調整を可能にする

`DragToResizeArea`で囲って、ドラッグしてウィンドウサイズを調整できる箇所を指定します。
ここでは、ウィンドウ全体でサイズ調整できるように`Scaffold`を指定します。

```diff_dart:main.dart
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ClipRRect(
        borderRadius: const BorderRadius.all(Radius.circular(7.5)),
+       child: DragToResizeArea(
          child: Scaffold(
            body: Column(
              children: [
                Expanded(
                  flex: 1,
                  child: DragToMoveArea(
                    child: Container(
                      color: Colors.blue,
                    ),
                  ),
                ),
                const Expanded(
                  flex: 19,
                  child: Center(
                    child: Text('Hello World!'),
                  ),
                ),
              ],
            ),
          ),
+       ),
      ),
    );
  }
```

childの他に指定できる要素として、
- サイズ調整に使えるエッジ・角の指定
- サイズ調整用の縁の色
- サイズ調整用の縁と外周との距離
- サイズ調整用の縁の幅
があります。
うまく使えば縁の判定を甘くしたり、変な場所でサイズ調整できるようになったりします。

```dart:main.dart
          resizeEdgeColor: Colors.red,
          resizeEdgeMargin: const EdgeInsets.all(10.0),
          resizeEdgeSize: 3,
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/7df15a99-ac15-3543-41e0-94dcbe00e47c.png)

### タイトルバーとウィンドウコントロールを追加

タイトルバーの`Container`にchildとして`WindowCaption()`を追加すれば、Windows風のウィンドウコントロールが表示され使用可能になります。
`WindowCaption()`を使うことで`DragToMoveArea`を指定する必要もなくなります。
引数にタイトル、背景色も指定できます。

```diff:main.dart
                Expanded(
                  flex: 1,
-                 child: DragToMoveArea(
-                   child: Container(
-                     color: Colors.blue,
-                   ),
-                 ),
+                 child: WindowCaption(
+                   title: Text(
+                     'test',
+                     style: TextStyle(color: Colors.black),
+                   ),
+                   backgroundColor: Colors.amber,
+                 ),
                ),
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/ed60daee-b31d-a77a-8301-3036b78b5aa5.png)

ただし、このままではmacOSでアプリを起動してもWindows風のウィンドウコントロールが表示されるし、
このタイトルバーは違う…って方向けに自作することもできます。

例として、`DragToMoveArea`>`Container`のchildとして以下のWidgetを追加します。

```dart
Row(
  mainAxisAlignment: MainAxisAlignment.end,
  children: [
    IconButton(
        onPressed: () {
          windowManager.minimize();
        },
        icon: Icon(Icons.minimize)),
    IconButton(
        onPressed: () async {
          bool isMaximized =
              await windowManager.isMaximized();
          if (isMaximized) {
            windowManager.unmaximize();
          } else {
            windowManager.maximize();
          }
        },
        icon: Icon(Icons.rectangle_outlined)),
    IconButton(
        onPressed: () {
          windowManager.close();
        },
        icon: Icon(Icons.close)),
  ],
),
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/d66d789b-114c-72e3-2ad9-0c2b5604fb02.png)

ボタンのonPressed動作にそれぞれ最小化、最大化・戻す、閉じるを割り当てました。
これでひととおりウィンドウの操作ができるようになりましたので、あとは需要に合わせてタイトルを追加したり他のボタンを足したりしてカスタムすればいいかと思います。

#### macOSはmacOSで元のタイトルバーかウィンドウコントロールを使いたい！って時に

`dart.io`の`Platform.is*`で使っているOSの判定ができます。
これを使って、OSごとで処理を分岐させることができます。

macOSの方で元のタイトルバーもしくはウィンドウコントロールを使いたい場合、`main()`を以下のように変更します。

```diff_dart:main.dart
void main() async {
  // 初期化
  WidgetsFlutterBinding.ensureInitialized();

  // window_managerを初期化
  windowManager.ensureInitialized();
  // ウィンドウプロパティを指定
  WindowOptions windowOptions = WindowOptions(
    size: const Size(1280, 720),
    backgroundColor: Colors.transparent,
    center: true,
+   // ウィンドウのタイトル
+   title: 'Flutterウィンドウサイズの話',
+   // タイトルバーのスタイル、`normal`で通常表示、`hidden`で非表示
+   // ウィンドウがフレームレスの場合、非表示される
+   titleBarStyle: Platform.isMacOS ? TitleBarStyle.normal : null,
+   // ウィンドウコントロールの表示、trueで表示
+   // ウィンドウがフレームレスの場合、非表示される
+   windowButtonVisibility: Platform.isMacOS ? true : null,
+   // タスクバーで非表示にするかどうかの設定
+   skipTaskbar: false,
  );

  // ウィンドウを表示
  windowManager.waitUntilReadyToShow(windowOptions, () async {
    // Windowsの場合フレームレス化
-   await windowManager.setAsFrameless();
+   if (Platform.isWindows) await windowManager.setAsFrameless();
    await windowManager.show();
    await windowManager.focus();
  });

  runApp(const MainApp());
}
```

レイアウトもWindowsとmacOSで切り替えるようにします。
まずはそれぞれのOS向けのレイアウトWidgetを作成します。
長いので折り畳めます。

<details>

```dart:main.dart
class LayoutForWindows extends StatefulWidget {
  final Widget child;
  const LayoutForWindows({super.key, required this.child});

  @override
  State<LayoutForWindows> createState() => _LayoutForWindowsState();
}

class _LayoutForWindowsState extends State<LayoutForWindows> {
  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      // Windowsのデフォルトアプリでは半径7.5になっているので合わせる
      borderRadius: const BorderRadius.all(Radius.circular(7.5)),
      // ドラッグしてウィンドウのサイズを調整できる範囲を指定
      child: DragToResizeArea(
        child: Scaffold(
          body: Column(
            children: [
              Expanded(
                flex: 1,
                // ドラッグしてウィンドウを移動できる範囲を指定
                child: DragToMoveArea(
                  // タイトルバー代わりのContainer
                  child: Container(
                    color: Colors.blue,
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.end,
                      children: [
                        // 最小化
                        IconButton(
                            onPressed: () {
                              windowManager.minimize();
                            },
                            icon: const Icon(Icons.minimize)),
                        // 最大化・戻す
                        IconButton(
                            onPressed: () async {
                              bool isMaximized =
                                  await windowManager.isMaximized();
                              if (isMaximized) {
                                windowManager.unmaximize();
                              } else {
                                windowManager.maximize();
                              }
                            },
                            icon: const Icon(Icons.rectangle_outlined)),
                        // 閉じる
                        IconButton(
                            onPressed: () {
                              windowManager.close();
                            },
                            icon: const Icon(Icons.close)),
                      ],
                    ),
                  ),
                ),
              ),
              Expanded(
                flex: 19,
                child: widget.child,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// macOS向けのレイアウトを提供するウィジェット。
///
/// このウィジェットは、macOSのデスクトップアプリケーションで使用するレイアウトを提供します。
/// Windowsのようなカスタムタイトルバーやウィンドウ操作ボタンは含まれていません。
///
/// - [child] は、ウィンドウの主要な内容を表示するウィジェットです。
class LayoutForMacOS extends StatefulWidget {
  final Widget child;
  const LayoutForMacOS({super.key, required this.child});

  @override
  State<LayoutForMacOS> createState() => _LayoutForMacOSState();
}

class _LayoutForMacOSState extends State<LayoutForMacOS> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: widget.child,
    );
  }
}

```

</details>

これで`MainApp`クラスもかなりスッキリします。
`Widget child`はテキトーに作ったメインコンテンツ変数なんて適宜に変更してください。

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

  @override
  Widget build(BuildContext context) {
    Widget child = const Center(
      child: Text('Hello World!'),
    );
    return MaterialApp(
      home: Platform.isWindows
          ? LayoutForWindows(child: child)
          : LayoutForMacOS(child: child),
    );
  }
}
```

![スクリーンショット 2024-11-12 11.42.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/e59083d2-e118-593e-7ca9-c15fd5ae8a7f.png)

<details>
<summary>titleBarStyleがhiddenの場合</summary>

![スクリーンショット 2024-11-12 11.44.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740657/d394278d-ddc9-ec4a-75a6-99c9793fcd8f.png)

</details>

## サンプルコード

最終的にコードは以下のようになりました。

https://github.com/YuuYabu/flutter_desktop_window_size

# おまけ

今回使った`window_manager`パッケージを拡張した`window_manager_plus`というパッケージがありますが、こちらのほうでは複数ウィンドウを取り扱うことも、ウィンドウ間でのデータやり取りもできます。
状況に応じて使いたいと思います。