Open17

XCTest: Test Planで言語を切り替えてテストする

kabeyakabeya

XCTestで、もともとはコードのほうに以下のようなのを書いて、言語を設定していました。

XCUIApplication().launchArguments += ["-AppleLanguages", "(ja)"]
XCUIApplication().launchArguments += ["-AppleLocale", "ja_JP"]

Test Planという仕組みを使うと、外側の設定でLanguageとRegionを切り替えられるということを知って、早速やってみたのですが…

テストケース内の各種Assertに比較対象の文言をベタで書いているので、ケース(のクラス)自体を言語ごとに分けて書かないとうまくいかなかったです。

とは言え設定だけで分けてうまく行くテストケースもあり、上述のようにそうでないテストケースもあるので、うまく行くテストケース(個々のテストケースをより分けるのは大変なので、おおざっぱなグルーピングとして)だけを、別々のTest Planとして実行できるようにする、という感じがいいのかなと思います。

kabeyakabeya

いくつかの言語を切り替えてテストしていたら、tap()の呼び出しでFailed to scroll to visible (by AX action) TextFieldのようなメッセージが出て止まる、ということが発生しました。

TextFieldtap()すると、ソフトウェアキーボードが下から上がってきて、それに伴い画面全体が上にスクロールして、当のTextFieldが画面外に出てしまう、ということなのかどうなのか、画面の構成を少し変えたら(上下に余裕を持たせたら)発生しなくなりました。
手でシミュレータをいじっているときは、明示的にコマンド+Kでソフトウェアキーボードを表示させない限り、ソフトウェアキーボードが表示されませんので、気づきにくいのかも知れません。

言語によって、行間?とかスクロールする量とか、ソフトウェアキーボードのサイズとかが違うのかしら。
.keyboardType(.numberPad)を指定しているせいもあって、見た感じ、言語による違いはないような気がするのですが。

kabeyakabeya

Swift(なのかNSNumberFormatterなのか)、fr_FRの桁区切りは、「(半角)スペース」(SPACE:U+0020)ではなく、「狭いノーブレークスペース」(NARROW NON-BREAKING SPACE:U+202F)。
スペースだと思ってテストコードを書いたらNGでした。
やっかいなのは、ソースコード上、この2つの見分けが付かないこと。どこまで直したのか…

Wikipediaを見ると、この狭いノーブレークスペースというのは、Unicode3.0でモンゴル語のために導入された、ということだけれども、フランス語で桁区切りに使っていたのだとしたら、導入タイミングとしては遅い気がするので、以前は違う文字を使っていたのではないかと想像。
他の環境(Windowsとか)でも、この文字なのかしら。

kabeyakabeya

以前は違う文字を使っていたのではないかと想像。

たぶん「ノーブレークスペース」(NON-BREAKING SPACE:U+00A0。俗に言う )なんでしょうね。
元々のソースコードも単純に間違いです。

kabeyakabeya

NumberFormatterはロケールがar_SAだと、minusSignに「U+061C」「U+002D」の2文字をぶち込んできます。
「U+002D」は普通の「-」なんだけれども、それに加えて「U+0061C」が付いています。
調べるとこの「U+061C」は「ARABIC LETTER MARK」で、コンピュータ処理用の「左から右に読む言語に切り替える用」マーク、ということらしいです。逆は「U+200F」の「RIGHT TO LEFT MARK」。

ロケールをar_SAにしたNumberFormatterを使って、-3を文字列に変換してXcodeのPlayground上でprintすると「٣-」のように後ろにマイナスが付いた文字列がコンソールウィンドウに出力されるのですが、この文字列をコピーして、ソースコードの二重引用符内にペーストすると「-٣」のように順番が逆になります。

単純に出力結果を確認するだけならいいんですが、編集をできるようにする場合、どのような順でテキストを入力してきたものをどう判定するのか、よく分かりません。

kabeyakabeya

ロケールがar_SA(アラビア語-サウジアラビア)の場合のマイナスの扱いを調べました。というかChatGPT 3.5に教えてもらいました。


アラビア語で負数を表す場合、マイナス記号が数字の前につく場合と後ろにつく場合があります。これらの違いには、以下のような点があります。

  1. 表示方法
    マイナス記号が数字の前につく場合、アラビア語で「منفي」(munfi) と呼ばれます。これは、数値を負の値として表現する方法の一つです。一方、マイナス記号が数字の後ろにつく場合、アラビア語で「سالب」(salib) と呼ばれます。これは、数値を正の値として表現し、その後にマイナス記号を付けて負の値として表現する方法です。
    例えば、「-5」という負数を表す場合、munfi表記では「-٥」と書きますが、salib表記では「٥-」と書きます。

  2. 読み方
    マイナス記号が数字の前につく場合、「munfi」と呼ばれる方法では、数字の読み方は「負の数」となります。一方、「salib」と呼ばれる方法では、数字の読み方は「正の数のマイナス」になります。

  3. 表記上の扱い
    マイナス記号が数字の前につく場合、「-」は数値の一部として認識されます。そのため、数値としての演算(加算、減算、乗算、除算)を行うことができます。
    一方、マイナス記号が数字の後ろにつく場合、数値の部分だけを抜き出して演算することができますが、マイナス記号は演算対象にはなりません。そのため、演算を行う場合には、負数を表す「salib」表記を「munfi」表記に変換する必要があります。

以上のように、マイナス記号が数字の前につく場合と後ろにつく場合では、表記方法、読み方、表記上の扱いに違いがあります。しかし、どちらの方法を使って負数を表現しても、数学的には同じ意味を持ちます。


一般的に、アラビア語の負数表記には、通常のアラビア語数字にマイナス記号を付ける方法が使用されます。この場合、キーボードからマイナス記号を入力した後に、数字を入力します。ARABIC LETTER MARK は、マイナス記号の前に配置されることがありますが、入力が必要かどうかは、使用している入力方式やコンピューターシステムの設定によって異なります。

ただし、ARABIC LETTER MARK が必要な場合でも、一般的なキーボードでは、直接入力することができない場合があります。その場合は、ARABIC LETTER MARK を含む負数表記をコピー&ペーストすることになります。

総じて言えることは、アラビア語で負数を入力する際には、使用している入力方式やコンピューターシステムの設定に応じて、適切な方法を選択する必要があるということです。


負数を表す場合には、通常のアラビア語の数字に「-」を付けて表します。ARABIC LETTER MARK は、この「-」の前に配置されることがあります。

入力された文字列から数値に変換する場合、ARABIC LETTER MARK は無視可能です。ARABIC LETTER MARK は、音声上の区切りを示すために使用されるため、数値の意味には影響を与えません。ただし、ARABIC LETTER MARK が数字の前にある場合、その数字を無視すると解釈されてしまう可能性があるため、適切な扱いが必要です。


ARABIC LETTER MARK が数字の前に配置される場合、主に以下のようなケースがあります。

  1. 数字の単位の区切りを示す場合
    アラビア語では、数字が大きくなるにつれて単位が変わるため、単位の区切りを明示する必要があります。たとえば、「1000」を表す場合、アラビア語では「ألف」という単位が使われますが、これは「1」を表す「١」の後ろに ARABIC LETTER MARK が付いた「١٬٠٠٠」で表現されます。

  2. 通貨表記でのマイナス符号の前に置かれる場合
    アラビア語では、通貨表記でのマイナス符号は数字の前に置かれます。例えば、「-100」を表す場合、アラビア語では「-١٠٠」と表記されますが、この場合には「-」の前に ARABIC LETTER MARK が付きます。


つまりどういうこと?

kabeyakabeya

NumberFormatter.localeがar_SAだった場合、NumberFormatter.number(from:)は以下のような動きをするようです。

  1. 入力文字列が"-"+数字だった場合(見た目上「-٣٫١」)
    マイナス数値に変換したNSNumberを返す。
  2. 入力文字列が数字+"-"だった場合(見た目上「٣٫١-」)
    nilを返す。
  3. 入力文字列がARABIC LETTER MARK+"-"+数字だった場合(見た目上「٣٫١-」)
    マイナス数値に変換したNSNumberを返す。
  4. 入力文字列が数字+ARABIC LETTER MARK+"-"だった場合(見た目上「٣٫١-」)
    nilを返す。

同じく、NumberFormatter.localeがar_SAだった場合、NumberFormatter.string(from:)は以下のような動きをするようです。

  1. 入力が正の数だった場合(例:3.1)
    普通に「٣٫١」を返す。
  2. 入力がマイナスの整数だった場合(例:-3.1)
    ARABIC LETTER MARK+"-"+正の数と同じ文字列(見た目上「٣٫١-」)を返す

NumberFormatterで数値のバリデーションを行う際、テキスト入力上は、

  1. "-"
  2. "-3"
  3. "-3."
  4. "-3.1"

というような順で打っていく、と考えたとき、

  • 1.の"-"だけのケースをどう扱うか(NumberFormatter.minusSignはARABIC LETTER MARK+"-"という文字列なので、"-"単体をマイナス記号扱いするにはどうするか)
  • 2.まで来たときに文字列("-3")→数値→文字列(ARABIC LETTER MARK+"-"+"3")となるので、元の文字列と異なるという判定になってしまうのをどうするか

という2つの問題がありそうです。

kabeyakabeya

Macで、アラビア語のMomayyezの小数点"٫"(ARABIC DECIMAL SEPARATOR:U+066B)を入力するには、キーボード設定から入力ソースとして「アラビア語-PC」か「アラビア語-QWERTY」を追加して、シフト+Kで入力できます。

キーボードビューアに表示されるキーと、実際にそのキーを押したときに入力される文字が違う(!)のでだいぶ探しました。
ChatGPTは「アラビア語-PCだと小数点の位置のキーを押して入力できます」と言いましたが、嘘でした。
普通にピリオド(FULL STOP:U+002E)が入りました。
ただ、あれかも知れません。キーボードの設定だけでなくて、システム言語とか地域とかも見るのかも。


(追記)
↑違いました。
Macの側の入力ソースを「アラビア語-PC」か「アラビア語-QWERTY」にしたうえで、iPhone Simulatorに向かってシフト+Kを押すと、iOSにはARABIC DECIMAL SEPARATORが入力される、ということのようでした。

同じことをMac側のアプリに向かってやってもシフト+Kはキーボードビューア記載の通りの文字が入ります。
Mac側にARABIC DECIMAL SEPARATORを打つ方法が分かりません…


(追記)
Macでは「アラビア語-PC」にして、オプションキーを押しながらバッククオートを押す(USキーボードの場合)とARABIC DECIMAL SEPARATORが入力できました。オプション+シフト+バッククオートだとARABIC THOUSAND SEPARATORが入力できます。
(ここでまた初耳のARABIC THOUSAND SEPARATORが出てきたので驚いてます)


Macの「絵文字と記号」(編集メニューの一番下あたりや、キーボードメニューにあります)でもARABIC DECIMAL SEPARATORを入力できます。

「絵文字と記号」を開いて、もし左側にメニュー(カテゴリ)がなければ、タイトルバーの左上の画面アイコン?のようなボタン(SF Symbolsでの「text.and.command.macwindow」)を押します。

左側のメニューには「絵文字」とか「囲み文字」とかあると思いますが、その中から「アラビア文字」を選びます。
「アラビア文字」がない場合は、左上のメニューから「リストをカスタマイズ…」を選んで「中東」→「アラビア文字」にチェックを入れて完了ボタンを押します。

「アラビア文字」を選ぶとたくさん文字が出ますが、スクロールバーのちょうど真ん中あたりが数字や記号の範囲になります。١٢٣のちょうど1段上あたりがARABIC COMMAやARABIC DECIMAL SEPARATORになるので、それをダブルクリックすると入力できます。

kabeyakabeya

ロケールをes_ES(スペイン語-スペイン)にしたときに、NumberFormatter.string(from:)が桁区切りをしたりしなかったりして困っています。

let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "es-ES")
formatter.numberStyle = .decimal

let s = formatter.string(from: 123400)
print("s: \(s)") // s: Optional("123.400")

Playgroundで上記のコードを実行すると桁区切りをするのに、いざiPhoneで実行すると桁区切りしません。

iPhone Simulator上の「ヘルスケア」アプリで例えば歩数=1000歩のレコードを追加して表示させてみると、以下のようになります。

  • 言語=日本語&地域=日本→桁区切りありの1,000歩
  • 言語=英語&地域=アメリカ合衆国→桁区切りありの1,000歩
  • 言語=ドイツ語&地域=ドイツ→桁区切りありの1.000歩
  • 言語=スペイン語&地域=スペイン→桁区切りなしの1000歩

iPhoneとMacで違うのかしら。


(追記)
よくよく動きを見ると、

  • 1234→1234
  • 12345→12.345
  • 123456→123.456

のように、4桁だと桁区切りせず、5桁以上だと桁区切りします。つまりそういうルールなんでしょうね。
色々ですね…

kabeyakabeya

色々なところで、スイスの桁区切りはアポストロフィ(公用語のイタリア語、フランス語、ドイツ語とも)、という話を見るのですが、NumberFormatterは、fr_CH(フランス語−スイス)の場合はNARROW NON-BREAKING SPACE、de_CH(ドイツ語-スイス)とit_CH(イタリア語-スイス)の場合はアポストロフィ(なんだけども「'」APOSTROPHE:U+0027ではなく、「’」RIGHT SINGLE QUOTATION MARK:U+2019)を使うようです。

同じ国のなかで、言語が違うのも大概ですが、数値の表記が違うって大変なことです。


(追記)
Playgroundで実行するとde_CHの桁区切りはアポストロフィなんですが、iOS Simulatorではなんと(de_DE同様)ピリオドです。なんでそんなところが違うんですかね…
(ちなみにit_CHもiOS Simulatorではit_IT同様、ピリオドでした)

kabeyakabeya

ここに至ってXCUIApplication().launchArguments += ["-AppleLocale", "ja_JP"]という指定が間違っているんじゃないかという気がします(あるいはいつからか無視されるようになった)。
-AppleLanguagesのほうしか効いてないので、フランス語だとかドイツ語、というだけで判断されている、と考えるとこの動きが説明できます。

実際に、iOS Simulatorの「一般」設定で、言語=ドイツ語&地域=スイスにすると、桁区切りがアポストロフィになります。


(追記)
結論から言うと、私のバグでした。

以下はAppleLocaleの指定が効きます。

let app = XCUIApplication()
XCUIApplication().launchArguments += ["-AppleLanguages", "(de)"]
XCUIApplication().launchArguments += ["-AppleLocale", "de-CH"]
app.launch()

以下はAppleLocaleの指定が効きません。

let app = XCUIApplication()
app.launchArguments += ["-AppleLanguages", "(de)"]
app.launchArguments += ["-AppleLocale", "de-CH"]
app.launch()

どういう理屈なのかな。どっちがうまくいきそうかと言えば後者のような気がしますが。

しかも後者でも言語は切り替わる(上の例だとドイツ語になる)ので見落としがち、ということでしょうか。


(追記)
済みません。嘘でした。
XCUIApplication().launchArguments += ["-AppleLanguages", "(de)"]および
XCUIApplication().launchArguments += ["-AppleLocale", "de-CH"]
はまったく効いてませんでした。
iOS Simulatorをde_CHに変えた状態にしていただけでした。

app.launchArguments += ["-AppleLanguages", "(de)"]および
app.launchArguments += ["-AppleLocale", "de-CH"]
でないとダメ。
ただAppleLocaleは確かに渡ってきてはいますが、それでスイスにはならない。なぜかな。


(追記)
Appのローカライズリソースが英語と日本語しかない場合では、上記の設定をして実行すると以下のようになるようです。

  • Locale.current = en_CH
  • Locale.preferredLanguages.first = de
  • Locale.current.region.identifier = CH

もし、本体がde_CHに設定されていて、Appが英語と日本語リソースしかないとして、入力や表示にde_CHのルールを使いたいというならば、上記の下2つを組み合わせて指定してやる必要がありそうです。

ここまでグダグダ言っていた処理は2番目のpreferredLanguagesしか使っていませんでした。

下2つを組み合わせたところ、桁区切りがアポストロフィになりました。

kabeyakabeya

UnicodeにNumberingSystemという、使える数字の体系が定義されています。

https://github.com/unicode-org/cldr/blob/main/common/supplemental/numberingSystems.xml

本日(2023/5/13)時点で、88個のNumberingSystemコードが登録されています。

NumberFormatterが利用するNumberingSystemを確認する

let systems = Locale.NumberingSystem.availableNumberingSystems
print("\(systems.count)")
for sys in systems {
    print("\(sys.identifier)")
}

上記のコードによると、Swift 5.8 macOS 13.3.1(a)では、「kawi」「nagm」以外の86個のNumberingSystemが使用できるようです。

サポートされている体系の整数だけなら、ロケールを設定しなくても数値化できます。
(小数点や桁区切りなどは、ロケールに依存してしまうのでロケールを指定しないと正確には変換できません)

let formatter = NumberFormatter()
let num_fullwide = formatter.number(from: "12345")
print("num_fullwide: \(num_fullwide)") // num_fullwide: Optional(12345)

let num_arab = formatter.number(from: "٢٣٤٥٦")
print("num_arab: \(num_arab)") // num_arab: Optional(23456)

と思ったのですが、「一二三四五」は、コード「hanidec」として定義されているにも関わらず変換できませんでした。
「jpan」で別の体系が定義されているからかな。

kabeyakabeya

SwiftのRegexで数字を引っかけるには、例えば以下のようにします。

let pattern = /[0-9]+/

let text = "現在の価格は2345円です。"

let matches = text.matches(of: pattern)
for match in matches {
    print("match: \(match.output)") // match: 2345
}

patternの部分の正規表現は、RegexBuillderを使うと

import RegexBuilder

let pattern = Regex<Substring> {
    OneOrMore(("0"..."9"))
}

のようにも書けます。
ただし、この正規表現はlet text = "現在の価格は2345円です。"のような全角文字表現にはマッチしません。

import RegexBuilder

let pattern = Regex<Substring> {
    OneOrMore(CharacterClass.generalCategory(.decimalNumber))
}

もしくは

import RegexBuilder

let pattern = Regex<Substring> {
    OneOrMore(.digit)
}

とすると、全角数字や漢数字、アラブ数字(インド数字)などにもマッチするようになります。

CharacterClass.generalCategory()はUnicodeのCharacter Categoryの文字を指定するクラスということです。
decimalNumberというクラスには以下のような文字が含まれています。
https://www.compart.com/en/unicode/category/Nd

ちなみに.digitというのは\dと等価だと書いてあります。
https://developer.apple.com/documentation/swift/regexcomponent/digit

Swiftの正規表現は、UnicodeのRegular Expressionsの定義に従う、ということなので、定義を見てみます。
https://unicode-org.github.io/icu/userguide/strings/regexp.html

これによると
\dは、「Match any character with the Unicode General Category of Nd (Number, Decimal Digit.)」ということなので、つまりCharacterClass.generalCategory(.decimalNumber)と等価です。

kabeyakabeya

NumberFormatterstring(from:)が出力する記号(小数点、桁区切り、マイナス記号およびそれに関連したもの)にどんなものがあるか、とりあえずLocale.availableIdentifiersで返ってくるロケールすべてで調べました。

let regex = /[^\d,.\-]/
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
let localeIds = Locale.availableIdentifiers
for localeId in localeIds {
    formatter.locale = Locale(identifier: localeId)
    let str = formatter.string(from: -1234567.089)!
    let matches = str.matches(of: regex)
    var nonDigitLetters = Set<String>()
    for match in matches {
        for c in match.0.unicodeScalars {
            nonDigitLetters.insert(String(format: c.value >= 0x10000 ? "U+%05X" : "U+%04X", c.value))
        }
    }
    if !matches.isEmpty {
        print("id: \(localeId) -> \(str)\t\(nonDigitLetters.joined(separator: ","))")
    }
}

結果としては、\dとピリオド、カンマ、ハイフン以外には、以下が出力されるようです。

U+0027: APOSTROPHE
U+00A0: NO-BREAK SPACE
U+061C: ARABIC LETTER MARK
U+066B: ARABIC DECIMAL SEPARATOR
U+066C: ARABIC THOUSANDS SEPARATOR
U+12C8: ETHIOPIC SYLLABLE WA
U+200E: LEFT-TO-RIGHT MARK
U+200F: RIGHT-TO-LEFT MARK
U+2019: RIGHT SINGLE QUOTATION MARK
U+202F: NARROW NO-BREAK SPACE
U+2212: MINUS SIGN
U+2E41: REVERSED COMMA
kabeyakabeya

NumberFormatterは、.numberStyle = .decimalにすると、number(from: "-12,345")に対して-12345を返します(.decimalにしないとマイナスは受け付けますが桁区切りを受け付けません)。
number(from: "一二三四五")は受け付けてくれない、というのは先のコメントで書きましたが、.numberStyle = .spellOutにするとnumber(from: "マイナス一万二千三百四十五")が-12345を返します。"一二三四五"はダメ。

数値入力を.spellOutでの表記もOK、とするのはちょっと無理があると思いますが、とは言え.decimalだけということにする場合でも、NumberFormatterが何を受け付けてくれるのか網羅的に調べる方法がありません。

受け付けてくれる記号も、CharacterClass.generalCategory(.decimalNumber)と同様にカテゴリ化してもらえてると良かったのですが。
ちなみに。

let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "ja-JP")
formatter.numberStyle = .decimal
let num = formatter.number(from: "-12,٣٤٥")
print("num: \(num)") // num: Optional(-12345)

NumberFormatterは、このように様々な数字種が混在していても(全角ハイフンマイナス、全角数字1、半角数字2、全角カンマ、アラブ数字345)文字列を数字に変換します。

このため何をエラーとするのか自分で決めるのは難しいと思います。
NumberFormatter.isPartialStringValid()が実装されていれば良さそうなのですが、これは派生クラスを自分で定義して、自分で実装しないといけないようです。

https://developer.apple.com/documentation/foundation/formatter/1415263-ispartialstringvalid

ちなみに、これを実装した派生クラスを用意して.isPartialStringValidationEnabled = trueにして、SwiftUIのTextFieldformatter:に指定しても呼び出されません。公式ドキュメントの記載の仕方を見るに、UIKitのコントロールでないとダメではないかと思います(試していません)。

部分文字列の入力中のエラーチェックは、-123.0405という文字列を順に入力していく際に、

  1. "-"
  2. "-1"
  3. "-12"
  4. "-123"
  5. "-123."
  6. "-123.0"
  7. "-123.04"
  8. "-123.040"
  9. "-123.0405"

のすべてのステップでその文字列を数値としてOKとみなす必要があるので、単にNumberFormatter.number(from:)で変換できればOK、という話ではないいやらしさがあります。特に1と5のステップ。
また、日本語はこうだけれども、マイナス記号の位置だとかによってもまた別の話があるのかも知れません。

正の整数に限定するのであれば、話はかなりシンプルでCharacterClass.generalCategory(.decimalNumber)に含まれない文字をエラーにしてしまえば良いです。

ちなみに、SwiftUIのTextFieldは、カレットの位置を制御できないので、仮に自前で色々なエラーチェックをできたとしても、上述のAppleのisPartialStringValidのドキュメントに書いてある「This method should be implemented in subclasses that want to validate user changes to a string in a field, where the user changes are not necessarily at the end of the string, and preserve the selection (or set a different one, such as selecting the erroneous part of the string the user has typed).」のような細かなことはできません。

isPartialStringValidは2種類のバージョンがあり、もう1個のバージョンには「The selection range will always be set to the end of the text if replacement occurs.」のようなことが書いてあります。SwiftUIの場合は、自前でエラーチェックしても、この動きしかできないと言えます。

https://developer.apple.com/documentation/foundation/formatter/1417993-ispartialstringvalid

kabeyakabeya

先の、

let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "ja-JP")
formatter.numberStyle = .decimal
let num = formatter.number(from: "-12,٣٤٥")
print("num: \(num)")

は、"ja-JP"こそ、-12345を返しますが、"es-ES"にすると、-12.345を返します。
ちゃんとFULLWIDTH COMMAを小数点と見なすところがすごいですね。

kabeyakabeya

色々試してきていますが現実的な路線で考えると、数値入力のバリデーションについて、国際化対応しましょうとなると、以下のような話なのかなと思い始めています。

  • リアルタイムでの入力チェックは、非負整数しか(厳密には)できない。非負整数以外(小数点、負数)は様々なルールが入ってくるため、入力途中の状態をチェックするのが難しく、厳密にやるのなら入力確定後にチェックするしかない。
  • 入力確定後のチェックはNumberFormatternumber(from:)で変換できるかどうか、で大丈夫。
  • ただし変換できるかどうかだけでは、どの文字がダメかまで判定するのは難しい。
  • 「リアルタイムのチェック」「どの文字がダメかまで示す」をするには、ある程度、仕様を限定するしかない。

「どの文字がダメかまで示す」は、そもそもどの数字書式を受け付けるか、限定する仕様にも色々と幅があると思いますので、ここでの議論は割愛しますが、リアルタイムチェックの例だけいくつか載せます。

非負整数のリアルタイムチェック

NG文字があれば元に戻すパターン

Regex\d以外の文字(つまり\D)があれば、元に戻すようにします。
ただし以下のような問題はあります。

  • ペーストの場合は、全文字戻る
  • 先頭0(例えば012345)は(入力中は)OKになる
struct ContentView: View {
    @State var text: String = "0"
    @State var intValue: Int = -1
    let regex = /\D/
    
    var body: some View {
        VStack {
            TextField("text input area", text: $text)
                .textFieldStyle(.roundedBorder)
                .onChange(of: text) { [oldText = self.text] newText in
                    print("\(oldText) -> \(newText)")
                    if let match = newText.firstMatch(of: regex) {
                        self.text = oldText
                    }
                }
                .onSubmit {
                    let formatter = NumberFormatter()
                    formatter.numberStyle = .decimal
                    if let number = formatter.number(from: self.text) {
                        intValue = number.intValue
                    }
                }
            Text("submit value: \(self.intValue)")
        }
    }
}

NG文字を取り除くパターン

Regex\dに該当する文字だけ抜き出して連結してフィールドに再設定します。
ペースト値からNGの文字だけ抜かれて貼り付けられますが、先頭0(012345)はそのままです。

struct ContentView: View {
    @State var text: String = "0"
    @State var intValue: Int = -1
    let regex = /\d+/
    
    var body: some View {
        VStack {
            TextField("text input area", text: $text)
                .textFieldStyle(.roundedBorder)
                .onChange(of: text) { [oldText = self.text] newText in
                    print("\(oldText) -> \(newText)")
                    let matches = newText.matches(of: regex)
                    var rebuild: String = ""
                    for match in matches {
                        rebuild.append(String(match.0))
                    }
                    if (newText != rebuild) {
                        self.text = rebuild
                    }
                }
                .onSubmit {
                    let formatter = NumberFormatter()
                    formatter.numberStyle = .decimal
                    if let number = formatter.number(from: self.text) {
                        intValue = number.intValue
                    }
                }
            Text("submit value: \(self.intValue)")
        }
    }
}

NG文字を取り除いたうえでNumberFormatterに文字→数字→文字変換させるパターン

NG文字を取り除いたあとNumberFormatterに数字変換させNGなら元に戻し、OKなら文字列に再変換させて再設定します。
ペーストしても不要な文字が除去されますし、先頭0(012345)も取り除かれます。

struct ContentView: View {
    @State var text: String = "0"
    @State var intValue: Int = -1
    let regex = /\d+/
    let formatter: NumberFormatter
    
    init() {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.usesGroupingSeparator = false
        self.formatter = formatter
    }
    var body: some View {
        VStack {
            TextField("text input area", text: $text)
                .textFieldStyle(.roundedBorder)
                .onChange(of: text) { [oldText = self.text] newText in
                    print("\(oldText) -> \(newText)")
                    let matches = newText.matches(of: regex)
                    var rebuild: String = ""
                    for match in matches {
                        rebuild.append(String(match.0))
                    }
                    if let number = self.formatter.number(from: rebuild) {
                        let intNum = number.intValue
                        let converted = self.formatter.string(from: NSNumber(integerLiteral: intNum))!
                        if (newText != converted) {
                            self.text = converted
                        }
                    }
                    else {
                        self.text = oldText
                    }
                }
                .onSubmit {
                    if let number = self.formatter.number(from: self.text) {
                        intValue = number.intValue
                    }
                }
            Text("submit value: \(self.intValue)")
        }
    }
}

ちなみにこれらは、全角数字やアラブ数字なども入ります。