signature パッケージによる署名機能の実装のサンプルアプリを作成した

## TL;DR

- [signature](https://pub.dev/packages/signature) パッケージの紹介
    - undo、redo、clear などの機能が用意されているので、ボタンクリック時に呼び出すだけでよい
    - 署名は ui.Image、PNG（Uint8List）、SVG などに変換可能
- 詳細な実装は [サンプルアプリ](https://github.com/macneko-ayu/flutter_signature_sample) を参照

## 概要

- 業務で [signature](https://pub.dev/packages/signature) パッケージを利用した署名機能を実装したので、備忘録も兼ねて紹介する
- 当該記事において、署名とは契約書などに記載するサインのことを指す

## 調査内容

### パッケージ選定

- Flutter アプリで署名機能を実現するためのパッケージはいくつかある。以下に例を挙げる
    - https://pub.dev/packages/signature
    - https://pub.dev/packages/syncfusion_flutter_signaturepad
    - https://pub.dev/packages/hand_signature
    - https://pub.dev/packages/flutter_signature_pad
- 今回はシンプルで使いやすそう、実装例がある、LIKE数・ダウンロード数がそれなりに多いという理由で、signature パッケージを採用した

### 作ったサンプルアプリ

- 署名エリア、undo ボタン、redo ボタン、clear ボタン、完了ボタン（署名反映エリアに取得した署名を反映する）、署名反映エリアというレイアウト構造のサンプルアプリを作成した

| プラットホーム | 動作 |
| --- | --- |
| iPhone | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81221/1a9a5034-cc9f-7dbb-0832-f555ddbd2607.gif"> |
| Android | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81221/5945d12d-a945-dd38-4fe5-a016d10f6b98.gif"> |
| macOS |  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81221/9bf5aee0-c5f9-8262-41b7-03847a2027d3.gif"> |
| Chrome | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81221/e5a2c47c-2a19-53af-bfb0-243ef37c2bb7.gif"> |



### 実装例

- 署名に関する変数を定義する
    
```dart
  late final SignatureController _controller; // 署名を管理するコントローラー
  bool _hasSignature = false; // 署名済かどうか
  ui.Image? _signatureImage;  // 署名データ
```

- `initState()` で SignatureController のインスタンスを作成する
    
```dart
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    // SignatureController のインスタンスを作成
    _controller = SignatureController(
      penStrokeWidth: 3.0, // 署名の線の太さ
      penColor: Colors.black, // 署名の色
    );

        // 署名完了時に呼ばれるコールバック
    _controller.onDrawEnd = () {
      setState(() {
        // 署名完了時に署名データの有無をフラグに代入
        // 署名の有無を _controller.isNotEmpty で判定したかったが、
        // 取得したいタイミングで意図した値にならない（署名が存在するのに false を返す）ことがあったので、
        // 別途フラグを用意している
        _hasSignature = _controller.isNotEmpty; 
      });
    };
  }
```
    
- パッケージの README に記載のある通り、忘れず dispose する
    
```dart
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _controller.dispose(); // dispose を忘れないこと
    super.dispose();
  }
```
    
- 画面回転したときに署名が残っていると署名エリア内に正しく描画されないので、クリアする
    
```dart
  @override
  void didChangeMetrics() {
    super.didChangeMetrics();
    _handleClear();
  }
```
    
- 完了ボタンをタップしたら署名反映エリアに署名画像を反映する
    
```dart
  Future<void> _handleComplete() async {
    final image = await _controller.toImage();
    setState(() {
      _signatureImage = image;
    });
  }
```
    
- クリアボタンをタップしたら、SignatureController の clear 関数を呼び出し、フラグや保持している署名画像も初期化する
    
```dart
  void _handleClear() {
    _controller.clear();
    setState(() {
      _hasSignature = false;
      _signatureImage = null;
    });
  }
```
    
- 署名エリアは Signature Widget で実現する
    
```dart
  child: Signature(
    controller: _controller,
    backgroundColor: Colors.white,
  ),
```
    
- undo 関数で直前の描画内容を取り消し、redo 関数で取り消した描画内容を復元できる
    
```dart
  IconButton(
    onPressed: _controller.undo,
    icon: const Icon(Icons.undo),
  ),
  IconButton(
    onPressed: _controller.redo,
    icon: const Icon(Icons.redo),
  ),
```
    
- SignatureController の toImage 関数で ui.Image に変換した署名画像を、RowImage Widget で描画する
    
```dart
  child: RawImage(
    image: _signatureImage,
    width: double.infinity,
    height: double.infinity,
    fit: BoxFit.contain,
  ),
```
    

### コード全体

- signature_page.dart
    
    ```dart
    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    import 'package:signature/signature.dart';
    
    class SignaturePage extends StatefulWidget {
      const SignaturePage({super.key});
    
      @override
      State<SignaturePage> createState() => _SignaturePageState();
    }
    
    class _SignaturePageState extends State<SignaturePage> with WidgetsBindingObserver {
      late final SignatureController _controller;
      bool _hasSignature = false;
      ui.Image? _signatureImage;
    
      static const double _buttonAreaHeight = 44.0;
      static const double _buttonSpacing = 16.0;
      static const double _contentPadding = 16.0;
    
      @override
      void initState() {
        super.initState();
        WidgetsBinding.instance.addObserver(this);
        _controller = SignatureController(
          penStrokeWidth: 3.0,
          penColor: Colors.black,
        );
    
        _controller.onDrawEnd = () {
          setState(() {
            _hasSignature = _controller.isNotEmpty;
          });
        };
      }
    
      @override
      void dispose() {
        WidgetsBinding.instance.removeObserver(this);
        _controller.dispose();
        super.dispose();
      }
    
      @override
      void didChangeMetrics() {
        super.didChangeMetrics();
        _handleClear();
      }
    
      Future<void> _handleComplete() async {
        final image = await _controller.toImage();
        setState(() {
          _signatureImage = image;
        });
      }
    
      void _handleClear() {
        _controller.clear();
        setState(() {
          _hasSignature = false;
          _signatureImage = null;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('署名'),
          ),
          body: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(_contentPadding),
              child: LayoutBuilder(
                builder: (context, constraints) {
                  final contentHeight = (constraints.maxHeight - _buttonAreaHeight - _buttonSpacing * 2) / 2;
                  return Column(
                    children: [
                      SizedBox(
                        height: contentHeight,
                        child: Container(
                          decoration: BoxDecoration(
                            border: Border.all(color: Colors.grey),
                            borderRadius: BorderRadius.circular(8),
                          ),
                          child: ClipRRect(
                            borderRadius: BorderRadius.circular(8),
                            child: Signature(
                              controller: _controller,
                              backgroundColor: Colors.white,
                            ),
                          ),
                        ),
                      ),
                      const SizedBox(height: _buttonSpacing),
                      SizedBox(
                        height: _buttonAreaHeight,
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                          children: [
                            IconButton(
                              onPressed: _controller.undo,
                              icon: const Icon(Icons.undo),
                            ),
                            IconButton(
                              onPressed: _controller.redo,
                              icon: const Icon(Icons.redo),
                            ),
                            IconButton(
                              onPressed: _handleClear,
                              icon: const Icon(Icons.clear),
                            ),
                            if (_hasSignature)
                              ElevatedButton(
                                onPressed: _handleComplete,
                                child: const Text('完了'),
                              ),
                          ],
                        ),
                      ),
                      const SizedBox(height: _buttonSpacing),
                      SizedBox(
                        height: contentHeight,
                        child: Container(
                          decoration: BoxDecoration(
                            border: Border.all(color: Colors.grey),
                            borderRadius: BorderRadius.circular(8),
                          ),
                          child: _signatureImage != null
                              ? ClipRRect(
                                  borderRadius: BorderRadius.circular(8),
                                  child: RawImage(
                                    image: _signatureImage,
                                    width: double.infinity,
                                    height: double.infinity,
                                    fit: BoxFit.contain,
                                  ),
                                )
                              : const Center(
                                  child: Text('署名を入力してください'),
                                ),
                        ),
                      ),
                    ],
                  );
                },
              ),
            ),
          ),
        );
      }
    } 
    ```
    
## まとめ

- なんとなく面倒なイメージがあった署名機能もパッケージを利用することで簡単に実装できた