[Swift] ParseableFormatStyle の作り方

     
⌛️ 3 min.

WWDC21で導入された FormatStyle, ParseStrategy, ParseableFormatStyle を説明してみます。複数回に分けて説明します。今回は ParseableFormatStyle を作ってみます。

FormatStyle, ParseableFormatStyle の 使い方/作り方を理解するための記事シリーズです。

[Swift] FormatStyle の使い方
[Swift] ParseableFormatStyle の使い方
[Swift] FormatStyle の作り方
[Swift] ParseableFormatStyle の作り方

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Ventura 13.2.1
  • Xcode 14.3 Beta
  • iOS 16.0

これまでの記事

前回の記事では、FormatStyle の使い方について説明しました。
・FormatStyle を使用すると データ を 適切な表現の String に変換できる
[Swift] FormatStyle の使い方

・ParseableFormatStyle を使用すると適切な表現の String をデータに変換できる
[Swift] ParseableFormatStyle の使い方

・独自型に対しての FormatStyle を作成
[Swift] FormatStyle の作り方

ParseableFormatStyle を作る

前回、電話番号(PhoneNumber)向け FormatStyle を作りましたが、今度は、文字列から PhoneNumber を生成できるように ParseableFormatStyle を作ってみます。

ParseableFormatStyle を説明した記事でも言及していますが、実装的には、ParseStrategy を作るのがポイントになります。

ParseStrategy

実際に、パース(文字列走査) を行うのが、ParseStrategy です。

Apple のドキュメントは、こちら

文字列から PhoneNumber を生成する ParseStrategy では、ParseInput が String、ParseOutput が PhoneNumber となります。

Apple の提供している (Date や 数値の) ParseStrategyは、CustomConsumingRegexComponent に conform して作られているものが多いですが、すべての ParseStrategy が conform しているわけではありません。

実際、ParseStrategy で規定されるメソッドは、parse だけです。

なお、Decimal 等の ParseStrategy の真似をして、PhoneNumber 内部に、PhoneNumber 用の ParseStrategy を定義していきます。
また ParseStrategy の init で、スタイルを受け取ることで、パースする文字列のスタイルを確定します。(これも、Decimal 等のパターンを踏襲しています)

ParseStrategyをテスト

先に、テストコードを書いていきます。

    func test_ParseStrategy_hyphen() async throws {
        let phone = PhoneNumber(areaCode: "045", localCode: "123", number: "4567")
        let str = phone.formatted(.phone(.hyphen)) // 045-123-4567
        let pnStrategy = PhoneNumber.ParseStrategy(format: .phone(.hyphen))
        XCTAssertEqual(try? pnStrategy.parse(str), phone)
    }

    func test_ParseStrategy_areaParenthesis() async throws {
        let phone = PhoneNumber(areaCode: "045", localCode: "123", number: "4567")
        let str = phone.formatted(.phone(.areaParenthesis)) // (045)123-4567
        let pnStrategy = PhoneNumber.ParseStrategy(format: .phone(.areaParenthesis))
        XCTAssertEqual(try? pnStrategy.parse(str), phone)
    }

    func test_ParseStrategy_localParenthesis() async throws {
        let phone = PhoneNumber(areaCode: "045", localCode: "123", number: "4567")
        let str = phone.formatted(.phone(.localParenthesis)) // 045(123)-4567
        let pnStrategy = PhoneNumber.ParseStrategy(format: .phone(.localParenthesis))
        XCTAssertEqual(try? pnStrategy.parse(str), phone)
    }

それぞれのスタイルでフォーマットしたものを ParseStrategy にパースさせた時に、同じ文字列になるかをテストしてます。

parse の実装

全体の方向性とテストはかけたので、あとは parse を実装するだけ(?) です。

parse の実装方法については、従来の正規表現を使っても良いですし、Regex Builder を使っても良いです。昔からある、Scanner を使っての実装でも問題ありません。

今回は、String のメソッドと Regex Builder を使って 実装してみます。

PhoneNumberParseError

気をつけるべき点の1つは、parse がうまくいかなったときは、nil を返すのではなく、エラーを throw することが必要な点です。
そのために、PhoneNumberParseError を定義しておきます。

extension PhoneNumber {
    enum PhoneNumberParseError: Error {
        case invalidFormat
    }
    // ... omit ...
}

どのような理由でパースできなかったかの理由等を細かく定義したい場合はこの enum が拡充されていくことになりますが、現時点では、想定しているフォーマットでなかったというエラーだけ定義しておきます。

.hyphen スタイル

.hyphen スタイルは、”-” で区切られているはずなので、String.split を使って、- で区切った文字列を取得し、その文字列を使うことにします。

extension PhoneNumber {
    // ...omit...
    public struct ParseStrategy: Foundation.ParseStrategy {
        public func parse(_ value: String) throws -> PhoneNumber {
            switch format.style {
            case .hyphen:
                let values = value.split(separator: "-")
                guard values.count == 3 else { throw PhoneNumberParseError.invalidFormat }
                guard values[2].count == 4,
                      (((values[0].count == 3) && (values[1].count == 3)) ||
                       ((values[0].count == 2) && (values[1].count == 4)) ) else { throw PhoneNumberParseError.invalidFormat }
                return PhoneNumber(areaCode: String(values[0]), localCode: String(values[1]), number: String(values[2]))
            // ...omit...
            }
            throw PhoneNumberParseError.invalidFormat
        }

“-” で区切った後に、構成要素が3つあることとそれぞれの長さの妥当性をチェックしています。
チェックを通った場合は、それらから PhoneNumber を生成して返しています。
… 数値であるということのチェックはしていないので、追加しても良いかもしれません。

.areaParenthesis スタイル

areaParenthesis スタイルについては、RegexBuilder を使って実装してみます。
.areaParenthesis スタイルの場合は、番号は (XXX)XXX-XXXX という形のはずです。

この状態を表す Regex は、以下のようになります。

extension PhoneNumber {
    public struct ParseStrategy: Foundation.ParseStrategy {
        static let areaRegex = Regex {
            "("
            Capture { Repeat(2...3) { CharacterClass.digit } }
            ")"
            Capture { Repeat(2...3) { CharacterClass.digit } }
            "-"
            Capture { Repeat(count: 4) { CharacterClass.digit } }
        }
     }
     // ... omit ...
}

Area 番号は 2〜3桁、Local番号も 2〜3桁、最後の番号は4桁という前提で、Area 番号は、カッコで囲われ、Local 番号と最後の番号の間は – で区切られています。

後から、該当箇所の文字列が必要となるので、それぞれを Capture しています。

そして、この Regex を使用して、以下のように処理します。

extension PhoneNumber {
    public struct ParseStrategy: Foundation.ParseStrategy {
        public func parse(_ value: String) throws -> PhoneNumber {
            switch format.style {
            case .areaParenthesis:
                if let match = value.wholeMatch(of: Self.areaRegex) {
                    let area = match.output.1
                    let local = match.output.2
                    let num = match.output.3
                    guard num.count == 4,
                          (((area.count == 3) && (local.count == 3)) ||
                           ((area.count == 2) && (local.count == 4)) ) else { throw PhoneNumberParseError.invalidFormat }
                    return PhoneNumber(areaCode: String(area), localCode: String(local), number: String(num))//String(values[2]))
                }
            // ... omit ...
            }
            throw PhoneNumberParseError.invalidFormat
        }
        // ...omit...
     }
     // ...omit...
}

Capture に名前をつけると output.1 というような表記をなくすことができるので、もう少し読みやすいコードになるかもしれません。

.localParenthesis スタイル

localParenthesis スタイルは、XXX(XXX)XXXX となるスタイルです。
areaParenthesis と同様に RegexBuilder を使って処理します。

extension PhoneNumber {
    // ...snip...
    public struct ParseStrategy: Foundation.ParseStrategy {
        // ...snip...
        public func parse(_ value: String) throws -> PhoneNumber {
            switch format.style {
            // ...snip...
            case .localParenthesis:
                if let match = value.wholeMatch(of: Self.localRegex) {
                    let area = match.output.1
                    let local = match.output.2
                    let num = match.output.3
                    guard num.count == 4,
                          (((area.count == 3) && (local.count == 3)) ||
                           ((area.count == 2) && (local.count == 4)) ) else { throw PhoneNumberParseError.invalidFormat }
                    return PhoneNumber(areaCode: String(area), localCode: String(local), number: String(num))//String(values[2]))
                }
            }
            throw PhoneNumberParseError.invalidFormat
        }

        static let localRegex = Regex {
            Capture { Repeat(2...3) { CharacterClass.digit } }
            "("
            Capture { Repeat(2...3) { CharacterClass.digit } }
            ")"
            Capture { Repeat(count: 4) { CharacterClass.digit } }
        }
    }
}

処理内容は、使っている Regex が異なるだけで、areaParenthesis と同様の処理内容です。

このように実装することで、さきほど書いたテストはパスするようになります。

TextField で使えるようにする

FormatStyle が ParseableFormatStyle にも conform していると、TextField で便利に使えます。
基本的な実装は終わっていますが、TextField でも使えるように、すこしだけ拡張しておきます。

[Swift] ParseableFormatStyle の使い方
上の記事でも確認していますが、typealias Strategy と var parseStrategy を定義することで、FormatStyle を、ParseableFormatStyle にすることができ、その FormatStyle を使用することで、SwiftUI の TextField で便利に使えます。

まずは、FormatStyle を ParseableFormatStyle に conform させます。

extension PhoneNumber.PhoneNumberFormatStyle: ParseableFormatStyle {
    public var parseStrategy: PhoneNumber.ParseStrategy {
        PhoneNumber.ParseStrategy(format: self)
    }
    public typealias Strategy = PhoneNumber.ParseStrategy
}

SwiftUI 上では、以下のように、使うことができます。

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2023/02/18
//  © 2023  SmallDeskSoftware
//

import Foundation
import SwiftUI

struct ContentView: View {
    @State private var number = PhoneNumber(areaCode: "012", localCode: "456", number: "1234")
    var body: some View {
        VStack {
            Text(number.formatted(.phone(.localParenthesis)))
            TextField("Phone", value: $number, format: .phone(.hyphen))
                .fixedSize()
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

動作は以下のようになります。

動作から想像するに、実装した parse が エラーを throw せずに PhoneNumber を返すことができると Binding に値がセットされているようです。

MEMO

上記のように、TextField を format: 付きで使用すると簡単にカスタムフォーマットをデータに変換して取り込めますが、取り込むタイミングは、parse が変換できたタイミングだけのようです。入力途中の状態をチェックして細かいサポート(?)をいれるためには、LocalBinding 等で入力途中もチェックすることが必要のようです。
throw されたエラーを取得する方法はわかりませんでした。(方法が 存在するかもわかりません・・・・)

まとめ

独自型に対して、ParseableFormatStyle, ParseStrategy の作り方を説明しました。

ParseableFormatStyle, ParseStrategy の作り方
  • ParseStrategy に conform させる
  • parse 実装は、RegexBuilder 等で実装する
  • データのparse 自体は、ParseStrategy もしくは、ParseableFormatStyle.Strategy の parse を使用する
  • FormatStyle をさらに ParseableFormatStyle に conform させると SwiftUI の TextField で便利

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です