Flutterで業務用バーコード読み取りアプリを作る(最小構成) の次に固めたいのが、キーボード入力として流れてくるスキャン値の扱いです。この記事では外部パッケージを使わず、TextField と FocusNode だけで、常時フォーカス、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 で見るなら、次の点を順に確認すると流れを追いやすくなります。
- 初期表示で
待機中が出ていること - Enter 確定後に入力欄が空へ戻ること
- 形式エラー時も
フォーカス再取得ボタンを押さずに次入力へ進めること 直前の生入力と最新受付コードが異なり、改行除去の結果が見えること スペースを含む入力を送ると、次のようにエラー表示を出しつつ待機状態へ戻せます。
6. まとめ
外部パッケージなしで、キーボード入力型のスキャン値を受ける最小構成を作りました。ここまでで、常時フォーカス、Enter 確定、不可視文字除去、形式エラー時の再入力導線がそろいます。
続けて取り組むなら、次の順がつながりやすいです。
- Flutterで業務用バーコード読み取りアプリを作る(最小構成) を見直し、カメラ入力とキーボード入力の違いを整理する
- FlutterでGS1-128バーコードを解析する を読み、受け取った文字列を GTIN・有効期限・重量へ分解する
- permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読み、実機機能へ進む前の権限設計を固める
- FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) を組み合わせ、受付済みの文字列を API へ送る