Flutter 小數點輸入問題:iOS 鍵盤因地區設定顯示逗號的解決方案

Flutter 小數點輸入問題:iOS 鍵盤因地區設定顯示逗號的解決方案

software-development

摘要

在 Flutter 中使用 TextInputType.numberWithOptions(decimal: true) 時,iOS 會根據裝置的地區設定顯示不同的小數點分隔符。在一些使用逗號(,)作為小數點的地區,如果你的 inputFormatters 只接受句點(.),使用者將無法輸入小數。本文將說明問題的根本原因並提供一個可重複使用的解決方案。


問題描述

有使用者回報在我們健康追蹤 App 的體重輸入欄位中無法輸入小數值。數字鍵盤雖然出現了,但按下小數點鍵卻沒有任何反應。

iOS 鍵盤顯示逗號作為小數點分隔符

查看截圖後,我們發現了一個關鍵點:鍵盤在小數點的位置顯示的是逗號(,)而不是句點(.

根本原因分析

iOS 如何處理小數點分隔符

iOS 在顯示數字鍵盤時會尊重裝置的地區設定。在許多地區(如台灣、歐洲大部分地區、南美洲),逗號被用作小數點分隔符:

地區小數點分隔符範例
美國、英國、中國. (句點)65.5
台灣、德國、法國, (逗號)65,5

當你設定 keyboardType: TextInputType.numberWithOptions(decimal: true) 時,Flutter 會告訴 iOS 顯示一個帶有小數點鍵的數字鍵盤。然而,iOS 會根據地區決定那個鍵輸出什麼字元

罪魁禍首:FilteringTextInputFormatter

我們最初的實作使用了:

TextFormField(
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [
    FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
  ],
  // ...
)

這個正規表達式 ^\d*\.?\d* 只接受:

  • 數字 (\d*)
  • 選擇性的一個句點 (\.?)
  • 更多數字 (\d*)

當台灣的使用者按下小數點鍵時,iOS 發送了一個逗號(,),由於不符合正規表達式的規則,格式化器(formatter)立即拒絕了該輸入。

解決方案

建立支援地區設定的小數點輸入格式化器

我們建立了一個自訂的 TextInputFormatter,它能夠:

  1. 接受 ., 作為小數點分隔符
  2. 將逗號標準化為句點,以保持解析的一致性
  3. 可選擇限制小數位數
import 'package:flutter/services.dart';

/// 一個允許使用 '.' 和 ',' 作為小數點輸入的 TextInputFormatter,
/// 並將 ',' 標準化為 '.' 以保持解析一致性。
///
/// 這處理了 iOS 地區差異,在某些地區數字鍵盤會顯示 ',' 而不是 '.'。
class DecimalTextInputFormatter extends TextInputFormatter {
  /// 小數位數限制。如果為 null,則不限制小數位數。
  final int? decimalPlaces;

  DecimalTextInputFormatter({this.decimalPlaces});

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // 為了地區相容性,將逗號替換為句點
    final newText = newValue.text.replaceAll(',', '.');

    // 根據小數位數限制建立正規表達式模式
    final pattern = decimalPlaces != null
        ? r'^\d*\.?\d{0,' + decimalPlaces.toString() + r'}$'
        : r'^\d*\.?\d*$';

    final regex = RegExp(pattern);

    if (newText.isEmpty || regex.hasMatch(newText)) {
      return TextEditingValue(
        text: newText,
        selection: newValue.selection,
      );
    }

    return oldValue;
  }
}

使用方式

FilteringTextInputFormatter 替換為我們的自訂格式化器:

// 修改前
TextFormField(
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [
    FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
  ],
)

// 修改後
TextFormField(
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [
    DecimalTextInputFormatter(),
  ],
)

對於需要限制小數位數的欄位(例如貨幣需要 2 位小數):

TextFormField(
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [
    DecimalTextInputFormatter(decimalPlaces: 2),
  ],
)

測試

遵循 TDD 原則,我們在實作前編寫了完整的測試:

group('DecimalTextInputFormatter', () {
  group('逗號轉句點轉換', () {
    test('應將逗號轉換為句點', () {
      final formatter = DecimalTextInputFormatter();
      final result = formatter.formatEditUpdate(
        const TextEditingValue(text: '123'),
        const TextEditingValue(text: '123,45'),
      );
      expect(result.text, '123.45');
    });

    test('應處理開頭為逗號的情況', () {
      final formatter = DecimalTextInputFormatter();
      final result = formatter.formatEditUpdate(
        const TextEditingValue(text: ''),
        const TextEditingValue(text: ',5'),
      );
      expect(result.text, '.5');
    });
  });

  group('無效輸入拒絕', () {
    test('應拒絕多個小數點', () {
      final formatter = DecimalTextInputFormatter();
      final result = formatter.formatEditUpdate(
        const TextEditingValue(text: '12.3'),
        const TextEditingValue(text: '12.3.4'),
      );
      expect(result.text, '12.3'); // 保持不變
    });
  });

  group('小數位數限制', () {
    test('應拒絕超過小數位數限制的輸入', () {
      final formatter = DecimalTextInputFormatter(decimalPlaces: 2);
      final result = formatter.formatEditUpdate(
        const TextEditingValue(text: '12.34'),
        const TextEditingValue(text: '12.345'),
      );
      expect(result.text, '12.34'); // 保持不變
    });
  });
});

重點總結

1. 永遠考慮國際化

即使是像數字輸入這樣簡單的功能,在不同地區也可能有不同的行為。請務必使用不同的地區設定測試您的 App。

2. 不要只依賴鍵盤類型

TextInputType.numberWithOptions(decimal: true) 只是建議顯示什麼樣的鍵盤。它並不保證輸入的會是什麼字元。

3. 儘早標準化輸入

透過在輸入階段轉換特定地區的字元,後續的程式碼(驗證、解析、儲存)就不需要處理多種格式。

4. 測試邊界情況

我們的測試套件涵蓋了:

  • 空輸入
  • 開頭小數點 (.5)
  • 結尾小數點 (123.)
  • 多個小數點分隔符
  • 混合逗號和句點輸入
  • 小數位數限制

受影響的平台

平台受影響原因
iOS小數點分隔符會根據裝置地區設定而變
Android部分有些鍵盤會尊重地區設定,有些則不會
Web瀏覽器地區設定會影響鍵盤行為

結論

這個這看似簡單的 Bug 教了我們重要的一課:永遠要使用不同的地區設定進行測試。在開發環境(通常是使用句點作為小數點的 美國/英國 地區)運作完美的程式,在其他地區的使用者手中可能會完全壞掉。

一旦了解了根本原因,修復方法就很直觀。透過建立可重複使用的 DecimalTextInputFormatter,我們現在有一個適用於所有地區的穩健解決方案,同時保持資料的乾淨和可解析性。


參考資料

FlutteriOSTextInputFormatterLocale國際化Keyboard行動開發