4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SwiftUIでMeasurementとTextFiledをFormatterで繋ぐ

Last updated at Posted at 2021-07-18

要約

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

概要

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

Measurementとは

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

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

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キロより長い
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に繋いで表示されることができます。

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

Measurementの場合

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

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

TextFieldとFormatterを繋ぐ

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

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

Measurementの場合

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

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のような、区切り文字や小数点を含む数値部と単位文字列に分かれるので、それらを解析して変換する、以下のようなクラスを用意してみました。

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を作成します。
定義済みの熱量のシンボルと比較する力技で対応しています。

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ジュールに変換されることがわかります。

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

まとめ

つかおう!Measurement!

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?