0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter Sccrible を手探る

Last updated at Posted at 2024-11-20

この記事について

この記事は、東京大学工学部電子情報工学科・電気電子工学科の後期実験「大規模ソフトウェアを手探る」の最終レポートとして作成したものである。

Flutter Scribbleとは

Flutter Scribbleは、Flutterを使用して手書き入力や描画を行うためのライブラリであり、主に以下の機能を提供している:

  • 手書き入力のキャプチャ
  • png形式への出力
  • カスタマイズ可能なペンの設定

Scribbleを活用することで、ユーザーインターフェースに直感的な手書き機能を簡単に追加することが可能。今回、Flutter Scribbleをオープンソースとして拡張することでios, android端末における手書き機能を充実させた。ここでの目標としては普段から愛用しているGoodNotesにある機能を可能な範囲で実装しようと手探った。

環境構築

実際に試した環境は、以下の通り:

  • OS: MacOS M2
  • Flutter SDK: バージョン3.24.0
  • Dart SDK: Flutterと同梱
  • IDE: Visual Studio Code
  • 実行環境: ipad 第10世代、ios simulator
  • その他ツール: Git, GitHub

今回追加した機能

今回追加した機能は以下の通り。

  • svg形式へのexport
    • Scribbleのrepositryのissueに書かれていたExport to SVGを実装
  • 書き込み履歴の読み込み
    • 手書きの書き込み済みの画像をimportし加筆修正できるようにした
  • 背景画像の読み込み:
    • 任意の画像を背景画像として取り込むことを可能にした
    • foreign ogject タグによって処理されるhtmlデータを含むsvgデータに関してはwebview_flutterを用いて別途対応した
  • 背景画像込みでのexport
    • 手書きの内容と上記で読み込み可能にした背景画像を同一の画像データとして保存することを可能にした

svg形式へのexport

手書き文字の状態管理について

Scribbleにおいて、手書き内容の状態管理はScribbleNotifierクラスが担っている。ここでは、描写範囲内での点を示すPoint,Pointの配列であるSketchLine,Lineの配列であるSketchによって手書き内容が管理されている。
つまり、Sketchのデータをsvgデータに変換することロジックがここで実装するべきメソッドそのものとなる。

名称未設定.png

実装方法について

上の指針通りに手書き内容をsvg形式のデータとして保存するためのtoSvgメソッドをScribbleNotifierに実装したgithub

実装方法としては、Sketch? sketch;Pointの二次元配列であることから全てのPoint

line.points.map((p) => '${p.x},${p.y}').join(' ');

によりString形式に変換して、polylineタグのpointsに格納している。

polylineタグ
polylineタグは、SVG内で複数の点を直線でつなぐ形状を描画するために使用される。
主に折れ線グラフや手書き軌跡の描画などに利用され、points属性に点の座標を指定することで簡単に線を描画できる。
例: <polyline points="10,10 20,20 30,10" stroke="black" stroke-width="2" fill="none" />
上記の例では、点 (10,10)(20,20)(30,10) をつなぐ折れ線が描画される。
塗りつぶしが不要な場合はfill="none"を指定することが一般的。

最後に上記で変換した内容を<svg>タグで適当にラップすることでsvgデータとして出力することができるようになっている。

toSvgメソッド

// toSvg メソッド
  String toSvg() {
    final buffer = StringBuffer();

    <中略>

    buffer.writeln('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
    buffer.writeln(
      '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="$width" height="$height" viewBox="$minX $minY $width $height">',
    );

    for (final line in currentSketch.lines) {
      if (line.points.length < 2) continue;
      final pointsString = line.points.map((p) => '${p.x},${p.y}').join(' ');
      final color =
          '#${line.color.toRadixString(16).padLeft(8, '0').substring(2)}';
      final strokeWidth = line.width;
      buffer.writeln(
        '<polyline points="$pointsString" stroke="$color" stroke-width="$strokeWidth" fill="none" stroke-linecap="round" stroke-linejoin="round" />',
      );
    }

    buffer.writeln('</svg>');
    return buffer.toString();
  }

出力例

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="463.0" height="546.0"
  viewBox="63.5 207.0 463.0 546.0">
  <polyline
    points="236.0,376.0 243.5,364.5 261.5,344.0 304.0,290.5 335.5,250.0 355.5,222.0 365.5,207.0 360.0,215.0 310.0,287.5 252.0,386.5 179.5,508.5 98.5,661.5 76.5,710.0 65.0,742.0 63.5,753.0 95.0,732.0 205.0,623.5 300.0,524.0 353.5,463.0 385.5,426.0 404.5,406.0 399.5,415.5 374.0,457.5 361.0,483.0 357.0,494.5 364.0,495.0 394.0,472.5 439.5,428.5 452.5,415.5 463.5,404.0 457.5,410.0 434.5,437.0 422.0,454.5 417.5,465.5 427.0,462.0 434.5,455.5 441.0,452.0 440.5,454.0 425.5,474.5 416.0,488.5 411.5,496.5 418.0,494.5"
    stroke="#000000" stroke-width="5.0" fill="none" stroke-linecap="round" stroke-linejoin="round" />
  <polyline
    points="333.5,678.0 312.5,653.5 240.0,556.5 205.0,496.0 177.5,452.5 168.0,433.0 190.0,451.0 238.5,498.0 313.5,565.5 384.5,627.5 413.5,652.0 441.0,673.5 447.0,676.0 447.5,646.0 429.5,575.5 401.5,483.0 390.5,446.0 384.0,413.0 386.5,412.0 421.5,430.0 457.0,448.0 471.5,451.5 493.5,445.5 489.5,353.5 469.5,288.0 462.0,267.5 452.5,234.0 456.0,235.5 487.5,264.5 504.0,278.0 522.0,291.0 526.0,292.5 526.5,288.0 524.5,274.0 521.5,264.5 520.5,260.5"
    stroke="#000000" stroke-width="5.0" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>

これに加えて、画面においてsvgへ出力するためのボタンを作成することで、以下のデモ動画のように書き込みデータをsvgデータとして出力することができるようになった。

書き込み履歴の読み込み

上記で作成したsvg形式の出力を前提としてsvgデータから書き込み内容を再現するためにloadFromSvgメソッドを実装した。後述するが、最初はただの画像データとしてインポートできる形で実装した。しかし、一度書いたものDBに保存した後、再現・編集できない点があまりに不便であったことから書き込みデータを書き込みデータとして(画像としてはなく)インポートできるように変更した。

実装方法としては、svgデータをStringデータとして受け取り、polylineタグを探し、その内部の点をSketchLineに変換している。このとき、追加で加筆ないしは線の消去を行うことができ書き込みデータの保存と再現が可能となった。

この実装では、書き込みデータの保存と再現を重視したため、色の情報、太さの情報に関して十分な検証を行うことができない。GoodNotesにあるようなUXを再現するにはこれらの検証も必須となる。

  void loadFromSvg(String svgData) {
    final document = XmlDocument.parse(svgData);
    final svgElement = document.rootElement;

    // 書き込みデータの状態変数としての格納先
    final lines = <SketchLine>[];
    
    for (final polylineElement in svgElement.findAllElements('polyline')) {
      final pointsString = polylineElement.getAttribute('points');
      if (pointsString == null) continue;

      final points = pointsString.split(' ').map((point) {
        final coords = point.split(',');
        return Point(double.parse(coords[0]), double.parse(coords[1]));
      }).toList();
      
      final strokeColor = polylineElement.getAttribute('stroke') ?? '#000000';
      final color = _parseHexColor(strokeColor);

      final strokeWidth =
          double.parse(polylineElement.getAttribute('stroke-width') ?? '1.0');

      final sketchLine = SketchLine(
        points: points,
        color: color,
        width: strokeWidth,
      );

      lines.add(sketchLine);
    }

    final sketch = Sketch(lines: lines);
    setSketch(sketch: sketch);
  }

おまけ
次の章の背景画像の読み込みにおいて登場するWebViewWidgetを用いることでsvgデータとして出力した書き込みデータを画面に表示できるような実装を行なった。しかし、ここで問題となったのが処理が通常の画像を処理する場合よりも重い点、書き込みデータを読み込むというケースに対応できない点からこの実装方法を断念した。ちなみに、処理が重い原因はWebViewWidgetが内部でjavascriptを動かしていることに起因した。

背景画像の読み込み

svgを除く画像データの読み込み

png, jpgなどのピクセル画像に関してはflutterのdart.uiが提供するImageクラスで処理することにより画面描写はそのまま行うことができる。ここで、手書きを行う領域と画像表示の領域を重ねて、書き込み時のタップアクションを適切に処理するようにすることで実装することができた。

Positioned.fillRepaintBoundaryはユーザーのタップアクションの挙動を制限し、背景画像と同じ領域に書き込み領域を限定し、タップアクションを適切に認識するようために実装している。

ClipRect(
              child: Stack(
                children: [
                  if (backgroundImage != null)
                    Positioned.fill(
                      child: CustomPaint(
                        painter: BackgroundImagePainter(backgroundImage!),
                      ),
                    ),

                  Positioned.fill(
                    child: CustomPaint(
                      foregroundPainter: ScribbleEditingPainter(
                        state: state,
                        drawPointer: drawPen,
                        drawEraser: drawEraser,
                        simulatePressure: simulatePressure,
                      ),
                      child: RepaintBoundary(
                        key: notifier.repaintBoundaryKey,
                        child: Builder(
                          builder: (context) {
                            WidgetsBinding.instance.addPostFrameCallback((_) {
                              final renderBox =
                                  context.findRenderObject() as RenderBox?;
                              if (renderBox != null) {
                                print(
                                  "RepaintBoundary size: ${renderBox.size.width} x ${renderBox.size.height}",
                                );
                              }
                            });
                            return CustomPaint(
                              painter: ScribblePainter(
                                sketch: state.sketch,
                                scaleFactor: state.scaleFactor,
                                simulatePressure: simulatePressure,
                              ),
                            );
                          },
                        ),
                      ),
                    ),
                  ),

                  // ジェスチャーイベントの処理
                  if (state.active)
                    GestureCatcher(
                      pointerKindsToCatch: state.supportedPointerKinds,
                      child: MouseRegion(
                        cursor: drawPen &&
                                state.supportedPointerKinds
                                    .contains(PointerDeviceKind.mouse)
                            ? SystemMouseCursors.none
                            : MouseCursor.defer,
                        onExit: notifier.onPointerExit,
                        child: Listener(
                          onPointerDown: (details) {
                            // 描画範囲を超えないように制限
                            _handlePointerEventWithinBounds(details, context,
                                () {
                              notifier.onPointerDown(details);
                            });
                          },
                          onPointerMove: (details) {
                            _handlePointerEventWithinBounds(details, context,
                                () {
                              notifier.onPointerUpdate(details);
                            });
                          },
                          onPointerUp: notifier.onPointerUp,
                          onPointerHover: notifier.onPointerHover,
                          onPointerCancel: notifier.onPointerCancel,
                          child: Container(
                            color: Colors.transparent,
                          ),
                        ),
                      ),
                    ),
                ],
              ),
            );

svg画像データの読み込み

基本的にはpackage内のScribbleウィジェットのメソッドとして実装したが、foreign ogject タグによって処理されるhtmlデータを含むsvgデータに関してはwebview_flutterを用いて別途対応した。

webview_flutter の仕組み

webview_flutter パッケージは、Flutter アプリケーション内にネイティブの WebViewWidget を埋め込むための公式パッケージである。このパッケージを利用することで、SVG の に対応する HTML のレンダリングを行う際、ネイティブ側の WebView エンジンを使用し、正確で高速な描画が実現可能。

flutterにおいてネイティブのAPIを使用する仕組み
platform_channels.png
(FlutterでAndroid/iOSのネイティブのAPIを使う)

具体的には、WebViewWidget を使用して WebView をウィジェットとして Flutter アプリケーション内に組み込み、次のような手順で動作する。

  • FlutterからネイティブWebViewの操作

    • WebViewController を介して HTML または SVG データを読み込み、JavaScript 実行やズーム操作を含む制御を行います
  • SVGデータの処理フロー

    • <foreignObject> を含む複雑な SVG を正確にレンダリングするため、ネイティブの WebView を利用して、HTML と JavaScript の解釈を行います。webview_flutter を使用することで、Flutter 側では直接描画できない複雑なデータも簡単に扱えるようになります。
  • WebViewのネイティブ実装

    • Android: android.webkit.WebView を使用。
    • iOS: WKWebView を使用。 これにより、デバイスごとのパフォーマンスを最大限に活かした描画が可能です。

WebViewWidget の実装

元々pngなどの画像形式と書き込み内容を同一画面に表示するためのウィジェットを囲んでいるClipRectと並列(builderがあるが機能的に並列)に並べることによってWebViewWidgetを同一の画面への表示を可能にした。
これにより

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        if (webViewController != null)
          WebViewWidget(
            controller: webViewController!,
          ),
        ValueListenableBuilder<ScribbleState>(
          valueListenable: notifier,
          builder: (context, state, _) {
            final drawCurrentTool =
                drawPen && state is Drawing || drawEraser && state is Erasing;

            return ClipRect(
              // ClipRectを追加して範囲外での描画を防ぐ
              child: Stack(
                children: [
                  // 背景画像を描画
                  if (backgroundImage != null)
                    Positioned.fill(
                      child: CustomPaint(
                        painter: BackgroundImagePainter(backgroundImage!),
                      ),
                    ),

                  Positioned.fill(
                    child: CustomPaint(
                      foregroundPainter: ScribbleEditingPainter(
                        state: state,
                        drawPointer: drawPen,
                        drawEraser: drawEraser,
                        simulatePressure: simulatePressure,
                      ),
                      child: RepaintBoundary(
                        key: notifier.repaintBoundaryKey,
                        child: Builder(
                          builder: (context) {
                            WidgetsBinding.instance.addPostFrameCallback((_) {
                              final renderBox =
                                  context.findRenderObject() as RenderBox?;
                              if (renderBox != null) {
                              // デバッグ用
                                print(
                                  "RepaintBoundary size: ${renderBox.size.width} x ${renderBox.size.height}",
                                );
                              }
                            });
                            return CustomPaint(
                              painter: ScribblePainter(
                                sketch: state.sketch,
                                scaleFactor: state.scaleFactor,
                                simulatePressure: simulatePressure,
                              ),
                            );
                          },
                        ),
                      ),
                    ),
                  ),
                  if (state.active)
                    GestureCatcher(
                      pointerKindsToCatch: state.supportedPointerKinds,
                      child: MouseRegion(
                        cursor: drawPen &&
                                state.supportedPointerKinds
                                    .contains(PointerDeviceKind.mouse)
                            ? SystemMouseCursors.none
                            : MouseCursor.defer,
                        onExit: notifier.onPointerExit,
                        child: Listener(
                          onPointerDown: (details) {
                            _handlePointerEventWithinBounds(details, context,
                                () {
                              notifier.onPointerDown(details);
                            });
                          },
                          onPointerMove: (details) {
                            _handlePointerEventWithinBounds(details, context,
                                () {
                              notifier.onPointerUpdate(details);
                            });
                          },
                          onPointerUp: notifier.onPointerUp,
                          onPointerHover: notifier.onPointerHover,
                          onPointerCancel: notifier.onPointerCancel,
                          child: Container(
                            color: Colors.transparent,
                          ),
                        ),
                      ),
                    ),
                ],
              ),
            );
          },
        ),
      ],
    );
  }

svg形式はforeign objectタグによってhtml形式のデータを画像データとして保存することができる。
しかし、flutterにおける最も一般的なsvgを扱うライブラリにflutter_svgがある。しかし、このflutter_svgパッケージは、foreign objectに対応していない。そのため、背景画像として以下のような画像を読み込もうとした場合、うまくいきません。そこで

スクリーンショット 2024-11-18 6.46.32.png

背景画像込みでのexport

svg形式の画像読み込みの機能拡張の延長線として、背景画像として取り込んだ内容を書き込みデータと同時に出力できるようにした。

この際、出力内容はsbgデータとなっており、元々のsvgデータに対して追加でforign object タグを追加することで同一の画像データとして出力できるようにしている。

拡張した機能の応用

今回作成したカスタマイズ版Scribbleパッケージを使用したプロダクトを作成してJPHACKSに参加してみた。ここでは教材と書き込み資料を同一の画面に表示するといった機能が必須であり、今回カスタマイズしたScribbleパッケージの理想的な使い方をすることができた。

発表資料
https://drive.google.com/file/d/1MD56qhrZ1j5CFmKhCNBv6RAFLnHGxrb6/view?usp=sharing

実装できなかった機能

ここで紹介する機能に関しては目標にしていたGoodNotesの機能を再現する上で実装したいと最初に掲げた課題のうち技術的・時間的理由から断念したものである。これらの実装上の課題に関してもかなりの時間をかけてデバッグなどを行なったので、簡単に紹介することにする。

消しゴムの部分修正

経緯

デフォルトのScribbleパッケージでは、一筆書きの線がまとめて削除されてしまう。そのため、線の特定の点を消すことができず、GoodNotesのような便利さを提供できない。

実装上の問題

現在、Sketchとして書き込みデータの点の二次元配列を格納しているデータを画面上に表示する方法としてdart.uiPathに変換することで表示している。しかし、ここで点のデータから一定の太さを持つ線を表現する処理はPathの内部で処理されており、以下に示すような線画を描くためのメソッドによって手書きの内容が表現されている。
ここで、Sketchの特定の点を削除した場合Path内部で線画を表現する処理が行われてしまい、上記の動画にあるような意図しない挙動をとってしまう。これを修正するには依存しているパッケージの内部を変更する必要があり、変更箇所が膨大になりうるかつScribbleの拡張とは少し趣旨がずれうるという理由から実装を断念した。

Pathクラス内の描写に関わるメソッドの例

  void moveTo(double x, double y);

  void relativeMoveTo(double dx, double dy);

  void lineTo(double x, double y);

  void relativeLineTo(double dx, double dy);

  ...

曲線に変な補正が入ってしまう

経緯

デフォルトだと以下の動画にあるように曲線が厳密になぞった線上ではなく曲線になるように適当な補正が入っている。これでは、文字を書く上で不便となりうる。そのため、この補正の排除ないしは切り替えができるようにするべきという指摘があった。

実装上の問題

端的にいってし前歯、これも先ほどの例と同様Scribbleパッケージの実装内容が問題ではなく、依存しているパッケージ依存であると判明したことから、仮に実装した場合、依存しているパッケージを作り直す必要があり時間的な制約上授業時間内に終わらないという判断で実装を断念した。

開発において苦闘した工程

Flutterプロジェクト構成

以下は、今回使用したプロジェクトのディレクトリ構成をツリー形式で表示したものである。FLutterプロジェクト内のディレクトリ構成に関して簡単説明する。

.
├── android                         # Androidビルド関連ファイル
├── ios                             # iOSビルド関連ファイル
├── macos                           # macOSビルド関連ファイル
├── web                             # Web用ビルドファイル
├── lib
│   ├── main.dart                    # アプリケーションエントリーポイント
│   ├── scribble.dart                # Scribbleライブラリのエントリーポイント
│   └── src                          # Scribbleの実装ディレクトリ
│       ├── domain                   # ドメイン層(データやモデル)
│       │   ├── iterable_removed_x.dart
│       │   └── model
│       │       ├── point
│       │       │   ├── point.dart
│       │       │   ├── point.freezed.dart
│       │       │   └── point.g.dart
│       │       ├── sketch
│       │       │   ├── sketch.dart
│       │       │   ├── sketch.freezed.dart
│       │       │   └── sketch.g.dart
│       │       └── sketch_line
│       │           ├── sketch_line.dart
│       │           ├── sketch_line.freezed.dart
│       │           └── sketch_line.g.dart
│       ├── view                     # 表示層およびUIロジック
│       │   ├── notifier
│       │   │   └── scribble_notifier.dart
│       │   ├── painting             # 描画ロジック
│       │   │   ├── converter.dart
│       │   │   ├── point_to_offset_x.dart
│       │   │   ├── scribble_editing_painter.dart
│       │   │   ├── scribble_painter.dart
│       │   │   └── sketch_line_path_mixin.dart
│       │   ├── pan_gesture_catcher.dart
│       │   ├── scribble.dart
│       │   ├── scribble_sketch.dart
│       │   ├── simplification       # スケッチの簡略化ロジック
│       │   │   └── sketch_simplifier.dart
│       │   └── state                # 状態管理
│       │       ├── scribble.state.dart
│       │       ├── scribble.state.freezed.dart
│       │       └── scribble.state.g.dart
├── packages                         # サブパッケージ
│   ├── simpli                       # スケッチ簡略化用のパッケージ
│   │   ├── lib
│   │   │   ├── simpli.dart
│   │   │   └── src
│   │   │       ├── data
│   │   │       │   ├── rdp_simplifier.dart
│   │   │       │   └── visvalingam_simplifier.dart
│   │   │       ├── domain
│   │   │       │   └── simplifier.dart
│   │   └── test                     # テストコード
│   └── value_notifier_tools         # ValueNotifier関連のユーティリティ
│       ├── lib
│       │   ├── value_notifier_tools.dart
│       │   └── src
│       │       ├── history_value_notifier
│       │       └── where_value_notifier
│       └── test
│           ├── select_value_notifier
│           └── where_value_notifier
├── pubspec.yaml                     # パッケージマネジメントファイル
├── melos.yaml                       # Monorepo管理ファイル
├── scribble_demo.gif                # デモGIF
└── test                             # テスト関連ディレクトリ

ディレクトリとファイルの内容

androidディレクトリ

  • Androidアプリケーションのビルドに必要な設定ファイルを格納

iosディレクトリ

  • iOSアプリケーションのビルドに必要な設定ファイルを格納

**macosディレクトリ

  • macOSアプリケーションのビルドに必要な設定ファイルを格納
    webディレクトリ
  • Webアプリケーションのビルドに必要なファイルを格納

testディレクトリ

  • アプリケーションの各モジュールや機能をテストするコードを格納

packagesディレクトリ

  • モジュール化されたサブパッケージを管理。
    • 用途: 今回の実験では、このディレクトリ内のパッケージを編集し、機能の拡張を行った

libディレクトリ

  • Flutterアプリケーションのメインロジックを格納。
    • main.dart: アプリケーションのエントリーポイント。
    • scribble.dart: Scribbleライブラリのエントリーポイント。
    • src: ロジックやUIを実装。
      • domain: アプリケーションのモデルやデータ構造。
      • view: ユーザーインターフェースや描画ロジック。
      • state: 状態管理

その他の主要ファイル

  • pubspec.yaml: プロジェクトの依存関係やメタ情報を管理。
  • melos.yaml: Monorepoプロジェクトを管理する設定ファイル。
  • scribble_demo.gif: Scribbleの動作を示すデモ用のGIF画像

感想

初め、OSSなど多くのエンジニアが共同で開発している中で、エンジニアとして未熟な自分たちがコントリビュートできる開発が行えるのか疑問であったが、最終的には満足のいく形で機能開発を行うことができてよかった。また、失敗した機能開発から学んだこととして、依存しているライブラリの箇所に関しては修正が難しかったり、変更範囲が広いため着手しにくいことがわかった。また、天下り的にはなるが、機能が豊富なパッケージを充実させるには、依存しているパッケージそのものをカスタマイズするか、一から作る必要が出てくると実感した。
そして、今回実装したScribbleを応用して別のアプリを作ることができた点から、実験での機能拡張の有用性を感じることもできた。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?