SwiftUIでMeasurementとTextFiledをFormatterで繋ぐ


# 要約

MeasurementFormatterを継承したクラスを作って、MeasurementとTextFiledをバインディングします。

# 概要

SwiftUIのTextには、Formatterと、Formatterに対応する値を設定することで自動で表示を整える機能があります。
TextFieldにも同様にその機能があるのですが、Measurementでそのまま行うとクラッシュするので、その対応策を示します。

# Measurementとは
例えば長さについて考えると、身長を測る時の単位はcmですが、距離を測るときはkmだったりと単位が違います。
また海外では身長の単位はフィートだったり、距離を測るのはマイルだったりと、同じ概念を示すけど単位が違ったりします。
そういった違いを扱うためのクラスがMeasurementになります。

Measurementを使うことによって、海外ごとの単位に対応したり、違う単位同士の計算ができたりします。

```swift
let km = Measurement<UnitLength>.init(value: 2, unit: UnitLength.kilometers)
let mile = Measurement<UnitLength>.init(value: 3, unit: UnitLength.miles)
print(km < mile) // true 3マイルは4キロより長い
```

```swift
let hyuga = Measurement<UnitLength>.init(value: 185, unit: UnitLength.centimeters)
let george = Measurement<UnitLength>.init(value: 6.7, unit: UnitLength.feet)
let tom = Measurement<UnitLength>.init(value: 1, unit: UnitLength.fathoms)
print((hyuga + george + tom) / 3) // 三人の平均身長を算出
```

# TextとFormatterを繋ぐ
例えば、以下のように、`init<Subject>(Subject, formatter: Formatter)` を使うことによって、各種値をそのままFormatterに繋いで表示されることができます。

```swift
import SwiftUI

struct SwiftUIView: View {
    @State private var date = Date()
    @State private var number: NSNumber = -123.4
    
    var dateFormatter: DateFormatter {
        let f = DateFormatter()
        f.dateStyle = .long
        f.timeStyle = .long
        return f
    }
    
    var numberFormatter: NumberFormatter {
        let f = NumberFormatter()
        f.numberStyle = .decimal
        return f
    }

    var body: some View {
        VStack {
            Text(date, formatter: dateFormatter)
            Text(number, formatter: numberFormatter)
        }
    }
}
```

![スクリーンショット 2021-07-18 16.54.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62305/f5ea332e-2967-5edd-92a7-bb1ef96d7918.png)


## Measurementの場合

同様に、Measurementに対しても `MeasurementFormatter` が用意されていますので、そちらと繋いでTextの表示が可能です。

```swift
import SwiftUI

struct SwiftUIView: View {
    @State private var energy = Measurement<UnitEnergy>(value: 100, unit: UnitEnergy.kilocalories)
    
    var measurementFormatter: MeasurementFormatter {
        let f = MeasurementFormatter()
        f.unitOptions = [.providedUnit]
        return f
    }
    
    var body: some View {
        VStack {
            Text(energy, formatter: measurementFormatter)
        }
    }
}
```
![スクリーンショット 2021-07-18 16.53.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62305/50651a6d-c28b-57bf-5587-5c90c0c06cce.png)

# TextFieldとFormatterを繋ぐ
同様のやり方で、TextFieldもFormatterを組み合わせての実装ができます。
テキスト入力の結果のテキストが、NSNumberとして解析できる場合はnumberの更新が行われ、そうではないテキストの場合は更新されません。

```swift
struct ContentView: View {
    @State private var number: NSNumber = -123.4

    var numberFormatter: NumberFormatter {
        let f = NumberFormatter()
        f.numberStyle = .decimal
        return f
    }

    var body: some View {
        VStack {
            TextField("Number", value: $number, formatter: numberFormatter)
        }
    }
}
```

![Simulator Screen Recording - iPod touch (7th generation) - 2021-07-18 at 16.58.57.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62305/1e12f081-019e-f5eb-8c90-fbeb7f9a1d09.gif)

## Measurementの場合
Measurementの場合、以下のコードでの実装が想定されると思いますが、実際にこれを動かすとクラッシュします。

```swift
struct ContentView: View {
    @State private var energy = Measurement<UnitEnergy>(value: 100, unit: UnitEnergy.kilocalories)

    var measurementFormatter: MeasurementFormatter {
        let f = MeasurementFormatter()
        f.unitOptions = [.providedUnit]
        return f
    }

    var body: some View {
        VStack {
            TextField("Energy", value: $energy, formatter: measurementFormatter)
        }
    }
}
```

# クラッシュに対応する
クラッシュの原因としては、以下のログを見るとわかります。

```
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -getObjectValue:forString:errorDescription: only defined for abstract class.  Define -[NSMeasurementFormatter getObjectValue:forString:errorDescription:]!'
```

TextFiledから入力された文字列は、NumberFormatterであれば数値、のようにFormatterが型変換するのですが、もともとMeasurementFormatterは文字列からMeasurementへの変換に対応していないため、MeasurementFormatterを継承したクラスに
`func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool`
を実装して文字列からの変換に対応します。

## 文字列からMeasurementに変換できるクラス作成
TextFiledから来る想定の文字列は、 `4,184 J`のような、区切り文字や小数点を含む数値部と単位文字列に分かれるので、それらを解析して変換する、以下のようなクラスを用意してみました。

```swift
class StringMeasurementFormatter<MyUnitType>: MeasurementFormatter where MyUnitType : Dimension {
    override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        // NOTE: 文字列がMeasurementに変換できない場合はfalseを返す
        if let measurement = measurement(from: string)  {
            obj?.pointee = measurement as AnyObject
            return true
        } else {
            return false
        }
    }
    
    func measurement(from string:String) -> Measurement<MyUnitType>? {
        var value: Double?
        var unit: MyUnitType = MyUnitType.baseUnit()
        let numberFormatter = NumberFormatter()
        // NOTE: 国によって、桁数区切り文字や小数点は異なるので、NumberFormatterのものを使う
        let regularExpression = try! NSRegularExpression(pattern: "([0-9\(numberFormatter.groupingSeparator!)\(numberFormatter.decimalSeparator!)]+)|(\\S+)", options: [.caseInsensitive])
        let matches = regularExpression.matches(in: string, options: [], range: .init(location: 0, length: string.count))
        
        matches.forEach {
            let text = String(string[.init($0.range, in: string)!])
            if let d = numberFormatter.number(from: text.replacingOccurrences(of: numberFormatter.groupingSeparator, with: "")) {
                value = d.doubleValue
            } else if let getUnit = getUnit(string: text) {
                unit = getUnit
            }
        }
        
        if let value = value {
            return .init(value: value, unit: unit)
        } else {
            return nil
        }
    }
    
    func getUnit(string: String) -> MyUnitType? {
        fatalError("Must Override")
    }
}

```

そして、今回表示するものは熱量なので、さらに熱量用のFormatterを作成します。
定義済みの熱量のシンボルと比較する力技で対応しています。

```swift
class UnitEnergyFormatter: StringMeasurementFormatter<UnitEnergy> {
    override func getUnit(string: String) -> UnitEnergy? {
        switch string.lowercased() {
        case UnitEnergy.kilocalories.symbol.lowercased(): return .kilocalories
        case UnitEnergy.calories.symbol.lowercased(): return .calories
        case UnitEnergy.joules.symbol.lowercased(): return .joules
        case UnitEnergy.kilojoules.symbol.lowercased(): return .kilojoules
        case UnitEnergy.kilowattHours.symbol.lowercased(): return .kilowattHours
        default:
            return nil
        }
    }
}
```

# 動作させてみる
実際にこのUnitEnergyFormatterを適用して、TextFieldで単位をJ→kCalに変えて入力することにより、100キロカロリー = 418400ジュールに変換されることがわかります。

```swift
struct ContentView: View {
    @State private var energy = Measurement<UnitEnergy>(value: 100, unit: UnitEnergy.joules)

    var measurementFormatter: UnitEnergyFormatter {
        let f = UnitEnergyFormatter()
        return f
    }

    var body: some View {
        VStack {
            TextField("Energy", value: $energy, formatter: measurementFormatter)
        }
    }
}
```

![Simulator Screen Recording - iPod touch (7th generation) - 2021-07-18 at 18.21.46.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62305/7fe1defa-187f-4d05-6576-b22e0120dcfb.gif)

# まとめ
つかおう！Measurement！