公開日 2026-07-31

Flutterでスキャン入力を受けて処理する

Flutterでキーボード入力型のスキャン値を受け取り、常時フォーカス、Enter確定、不可視文字除去、形式エラー時の再入力導線までを1画面で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に入力処理の流れを掴む
  6. 3. プロジェクトを作成し、確認環境を用意する
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 4. lib/main.dart に入力欄と処理を実装する
  11. コードのポイント
  12. 5. エミュレーターで確認するポイント
  13. 6. まとめ

Flutterで業務用バーコード読み取りアプリを作る(最小構成) の次に固めたいのが、キーボード入力として流れてくるスキャン値の扱いです。この記事では外部パッケージを使わず、TextFieldFocusNode だけで、常時フォーカス、Enter キーでの確定、不可視文字や改行の除去、形式エラー時の再スキャン導線までを 1 画面で確認します。

1. ゴールと非対象

対象読者

  • Flutter プロジェクトを作成して flutter run した経験がある人
  • バーコード読み取り記事の次に、キーボードエミュレーション型のスキャン入力へ進みたい人
  • GS1-128 のような長い文字列を扱う前に、まず入力欄の受け方を整えたい人

この記事で到達する状態

  • スキャン入力欄を待機状態で常時フォーカスできる
  • Enter キーで入力確定し、次のスキャン待機へ戻せる
  • \r \n などの不可視文字や制御文字を除去できる
  • 形式エラー時にメッセージを出しつつ、入力欄へフォーカスを戻せる

非対象

  • カメラプラグインによる読み取り
  • GS1-128 の AI 解釈や日付変換
  • API 送信やファイル保存
  • MethodChannel による端末 SDK 連携

今回は「読み取った文字列を安全に受ける」ところに絞ります。後続のパースや保存は、その受け口が安定してから足すほうが切り分けしやすくなります。

2. 先に入力処理の流れを掴む

スキャン入力の流れは次の通りです。

flowchart TD
  A[入力欄がフォーカス待機] --> B[スキャナまたはキーボードで文字列入力]
  B --> C[Enterで確定]
  C --> D[改行と制御文字を除去]
  D --> E{可視ASCII 4〜120文字か}
  E -->|Yes| F[最新受付コードを更新]
  F --> G[履歴へ追加]
  G --> H[入力欄をクリア]
  H --> I[フォーカスを戻す]
  E -->|No| J[エラーメッセージを表示]
  J --> H

ここで先に見ておきたいのは、Enter キーそのものより、確定後にどの状態へ戻るかです。

  • 正常入力では、最新受付コードと履歴を更新してから入力欄を空にする
  • 改行や制御文字は保存前に除去する。スキャナによっては末尾に Enter が混ざるため
  • 形式エラーでも待機状態へ戻す。入力欄が外れたままだと次のスキャンを受けられない

この流れを先に決めておくと、後から GS1-128 パースや API 登録を足しても、どこに処理を差し込むべきか迷いにくくなります。

3. プロジェクトを作成し、確認環境を用意する

3-1. Flutter の環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

このサンプルは flutter run で確認できます。外部パッケージ不要のため、コードそのものは DartPad でも確認できます。ただし、常時フォーカスやスキャナのキーボード入力挙動の確認は flutter run で行ってください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成し、作業ディレクトリへ移動します。

flutter create my_scan_input_app
cd my_scan_input_app

3-3. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

一覧に出た ID を指定して起動します。

flutter emulators --launch <emulator_id>

Android Emulator で確認する場合は、PC キーボード入力をそのまま使えます。物理スキャナがなくても、PC から S-1001(01)04912345678903(17)260101 のような文字列を入力し、最後に Enter を押せば主要な流れを再現できます。

4. lib/main.dart に入力欄と処理を実装する

lib/main.dart は次の内容で作成します。

このファイルは、入力欄の常時フォーカス、Enter 確定、サニタイズ、形式エラー、履歴表示までを 1 画面で確認するサンプルです。注目点は _sanitizeScan() で制御文字を除去し、_submitScan() の最後で必ずフォーカスを戻しているところにあります。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Scan Input Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const ScanInputPage(),
    );
  }
}

class ScanEntry {
  const ScanEntry({
    required this.rawValue,
    required this.cleanedValue,
    required this.acceptedAt,
  });

  final String rawValue;
  final String cleanedValue;
  final DateTime acceptedAt;
}

class ScanInputPage extends StatefulWidget {
  const ScanInputPage({super.key});

  @override
  State<ScanInputPage> createState() => _ScanInputPageState();
}

class _ScanInputPageState extends State<ScanInputPage> {
  final TextEditingController _controller = TextEditingController();
  final FocusNode _focusNode = FocusNode(debugLabel: 'scan-input');
  final List<ScanEntry> _history = <ScanEntry>[];

  String _statusMessage = '入力欄を待機状態にしています。スキャン後に Enter を押してください。';
  String _lastAcceptedCode = '-';
  String _lastRawInput = '-';
  bool _hasFormatError = false;

  @override
  void initState() {
    super.initState();
    _focusNode.addListener(_handleFocusChange);

    WidgetsBinding.instance.addPostFrameCallback((_) {
      _requestScanFocus();
    });
  }

  @override
  void dispose() {
    _focusNode
      ..removeListener(_handleFocusChange)
      ..dispose();
    _controller.dispose();
    super.dispose();
  }

  void _handleFocusChange() {
    if (!mounted) {
      return;
    }

    setState(() {});
  }

  void _requestScanFocus() {
    if (!mounted) {
      return;
    }

    FocusScope.of(context).requestFocus(_focusNode);
  }

  void _requestScanFocusNextFrame() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _requestScanFocus();
    });
  }

  String _sanitizeScan(String value) {
    return value
        .replaceAll('\r', '')
        .replaceAll('\n', '')
        .replaceAll(RegExp(r'[\u0000-\u001F\u007F]'), '')
        .trim();
  }

  bool _isAcceptedFormat(String value) {
    final RegExp visibleAscii = RegExp(r'^[!-~]{4,120}$');
    return visibleAscii.hasMatch(value);
  }

  void _submitScan() {
    final String rawValue = _controller.text;
    final String cleanedValue = _sanitizeScan(rawValue);

    setState(() {
      _lastRawInput = rawValue.isEmpty ? '-' : rawValue;
    });

    if (cleanedValue.isEmpty) {
      setState(() {
        _hasFormatError = true;
        _statusMessage = '改行や制御文字を除去した結果が空になりました。再スキャンしてください。';
        _controller.clear();
      });
      _requestScanFocusNextFrame();
      return;
    }

    if (!_isAcceptedFormat(cleanedValue)) {
      setState(() {
        _hasFormatError = true;
        _statusMessage = '英数字や記号の連続文字列として解釈できませんでした。形式を確認して再スキャンしてください。';
        _controller.clear();
      });
      _requestScanFocusNextFrame();
      return;
    }

    final DateTime now = DateTime.now();

    setState(() {
      _hasFormatError = false;
      _statusMessage = '$cleanedValue を受け付けました。次のスキャンを待機しています。';
      _lastAcceptedCode = cleanedValue;
      _history.insert(
        0,
        ScanEntry(
          rawValue: rawValue,
          cleanedValue: cleanedValue,
          acceptedAt: now,
        ),
      );
      if (_history.length > 8) {
        _history.removeLast();
      }
      _controller.clear();
    });

    _requestScanFocusNextFrame();
  }

  String _formatTime(DateTime value) {
    final String hh = value.hour.toString().padLeft(2, '0');
    final String mm = value.minute.toString().padLeft(2, '0');
    final String ss = value.second.toString().padLeft(2, '0');
    return '$hh:$mm:$ss';
  }

  @override
  Widget build(BuildContext context) {
    final ColorScheme colorScheme = Theme.of(context).colorScheme;

    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: _requestScanFocus,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('スキャン入力デモ'),
        ),
        body: SafeArea(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Text(
                          _focusNode.hasFocus ? '待機中' : 'フォーカス外れ',
                          style: TextStyle(
                            fontSize: 18,
                            fontWeight: FontWeight.bold,
                            color: _focusNode.hasFocus
                                ? colorScheme.primary
                                : colorScheme.error,
                          ),
                        ),
                        const SizedBox(height: 8),
                        Text(_statusMessage),
                        const SizedBox(height: 12),
                        TextField(
                          controller: _controller,
                          focusNode: _focusNode,
                          autofocus: true,
                          textInputAction: TextInputAction.done,
                          decoration: InputDecoration(
                            labelText: 'スキャン入力欄',
                            hintText: '例: S-1001 または (01)04912345678903',
                            border: const OutlineInputBorder(),
                            errorText: _hasFormatError
                                ? '4〜120文字の可視 ASCII 文字列として再入力してください'
                                : null,
                            suffixIcon: Icon(
                              _focusNode.hasFocus
                                  ? Icons.radio_button_checked
                                  : Icons.radio_button_unchecked,
                            ),
                          ),
                          onSubmitted: (_) => _submitScan(),
                          onTapOutside: (_) => _requestScanFocus(),
                        ),
                        const SizedBox(height: 12),
                        Wrap(
                          spacing: 8,
                          runSpacing: 8,
                          children: <Widget>[
                            FilledButton.icon(
                              onPressed: _submitScan,
                              icon: const Icon(Icons.check),
                              label: const Text('確定'),
                            ),
                            OutlinedButton.icon(
                              onPressed: _requestScanFocus,
                              icon: const Icon(Icons.center_focus_strong),
                              label: const Text('フォーカス再取得'),
                            ),
                            TextButton.icon(
                              onPressed: () {
                                setState(() {
                                  _controller.clear();
                                  _hasFormatError = false;
                                  _statusMessage = '入力欄を待機状態に戻しました。';
                                });
                                _requestScanFocusNextFrame();
                              },
                              icon: const Icon(Icons.clear),
                              label: const Text('入力クリア'),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
                const SizedBox(height: 16),
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        const Text(
                          '直近の受付結果',
                          style: TextStyle(fontWeight: FontWeight.bold),
                        ),
                        const SizedBox(height: 8),
                        SelectableText('最新受付コード: $_lastAcceptedCode'),
                        const SizedBox(height: 4),
                        SelectableText('直前の生入力: $_lastRawInput'),
                      ],
                    ),
                  ),
                ),
                const SizedBox(height: 16),
                Expanded(
                  child: Card(
                    child: Padding(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          const Text(
                            '受付履歴(最新8件)',
                            style: TextStyle(fontWeight: FontWeight.bold),
                          ),
                          const SizedBox(height: 8),
                          Expanded(
                            child: _history.isEmpty
                                ? const Center(
                                    child: Text('まだ受付履歴がありません'),
                                  )
                                : ListView.separated(
                                    itemCount: _history.length,
                                    separatorBuilder: (_, __) =>
                                        const Divider(height: 1),
                                    itemBuilder: (BuildContext context, int index) {
                                      final ScanEntry item = _history[index];
                                      return ListTile(
                                        dense: true,
                                        title: Text(item.cleanedValue),
                                        subtitle: Text('raw: ${item.rawValue}'),
                                        trailing: Text(_formatTime(item.acceptedAt)),
                                      );
                                    },
                                  ),
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

コードのポイント

_sanitizeScan() で改行と制御文字を先に除去する

String _sanitizeScan(String value) {
  return value
      .replaceAll('\r', '')
      .replaceAll('\n', '')
      .replaceAll(RegExp(r'[\u0000-\u001F\u007F]'), '')
      .trim();
}

キーボードエミュレーション型のスキャナでは、末尾に Enter が付くことがあります。そのまま保存すると見た目では分かりにくい不正文字が残るため、保存前に \r \n と制御文字を落としておくほうが安全です。

_submitScan() の失敗分岐でも入力欄へ戻す

if (cleanedValue.isEmpty) {
  setState(() {
    _hasFormatError = true;
    _statusMessage = '改行や制御文字を除去した結果が空になりました。再スキャンしてください。';
    _controller.clear();
  });
  _requestScanFocusNextFrame();
  return;
}

if (!_isAcceptedFormat(cleanedValue)) {
  setState(() {
    _hasFormatError = true;
    _statusMessage = '英数字や記号の連続文字列として解釈できませんでした。形式を確認して再スキャンしてください。';
    _controller.clear();
  });
  _requestScanFocusNextFrame();
  return;
}

エラー表示だけで止めると、次のスキャンが入力欄へ入らないままになることがあります。成功時だけでなく失敗時でも _requestScanFocusNextFrame() を通すことで、再スキャン導線を安定させやすくなります。

FocusNode の状態を UI に見せると確認しやすい

Text(
  _focusNode.hasFocus ? '待機中' : 'フォーカス外れ',
  style: TextStyle(
    color: _focusNode.hasFocus
        ? colorScheme.primary
        : colorScheme.error,
  ),
)

常時フォーカスを狙う記事では、内部状態を見える形にしておくと確認が楽になります。エミュレーターや実機で「入力欄が外れたのか、形式エラーなのか」を切り分けやすくなるためです。

コードを貼り付けたら、次のコマンドで起動します。

flutter run

初期表示は次のようになります。待機状態、入力欄、直近の受付結果、履歴エリアを 1 画面で確認できます。

待機状態のスキャン入力デモ画面

5. エミュレーターで確認するポイント

スキャナが手元になくても、次の入力で主要挙動を確認できます。

入力例期待結果見るポイント
S-1001 + Enter正常受付最新受付コードが S-1001 に変わり、履歴が 1 件追加される
S-1001\r\n + Enter正常受付改行が除去され、履歴には S-1001 が残る
(01)04912345678903(17)260101 + Enter正常受付括弧付きの GS1 風文字列でも通る
ABC 123 + Enter形式エラースペースを含むためエラー表示になり、入力欄へフォーカスが戻る
Enter だけ押す空入力エラー空になったときも待機状態へ戻る

形式チェックを可視 ASCII にしているのは、次段で GS1-128 の括弧付き文字列も受けられるようにするためです。ここで英数字だけに絞りすぎると、次の記事で前提の作り直しが必要になります。

Android Emulator で見るなら、次の点を順に確認すると流れを追いやすくなります。

  1. 初期表示で 待機中 が出ていること
  2. Enter 確定後に入力欄が空へ戻ること
  3. 形式エラー時も フォーカス再取得 ボタンを押さずに次入力へ進めること
  4. 直前の生入力最新受付コード が異なり、改行除去の結果が見えること スペースを含む入力を送ると、次のようにエラー表示を出しつつ待機状態へ戻せます。
形式エラー時のスキャン入力デモ画面

6. まとめ

外部パッケージなしで、キーボード入力型のスキャン値を受ける最小構成を作りました。ここまでで、常時フォーカス、Enter 確定、不可視文字除去、形式エラー時の再入力導線がそろいます。

続けて取り組むなら、次の順がつながりやすいです。

  1. Flutterで業務用バーコード読み取りアプリを作る(最小構成) を見直し、カメラ入力とキーボード入力の違いを整理する
  2. FlutterでGS1-128バーコードを解析する を読み、受け取った文字列を GTIN・有効期限・重量へ分解する
  3. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読み、実機機能へ進む前の権限設計を固める
  4. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) を組み合わせ、受付済みの文字列を API へ送る

シリーズ 27/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する 現在の記事
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)