[Measurement] MeasurementとSwiftUIを使って、UnitConverterを作る件~MeasurementとMeasurementFormatterをうまく使う方法

Swift

Measurementで、単位間の変換が簡単になることが理解できたので、ず〜〜〜っと昔にチュートリアルで作った単位変換アプリを作ろうとしたのですが、思わぬところに難しさがありました。
忘備録も兼ねて、Measurement の使い方について改めて説明します。

Step1: Measurementを使って表現

Measurementを使うことで、Celsius/摂氏とFahrenheit/華氏の変換が簡単になることを試したいので、使います。

SwiftUIを使うので、複数のTextFieldで共有することを考えて、@Stateを使い定義します。

@StateとMeasurementを使って温度を定義
@State private var temperature = Measurement(value: 25.6, unit: UnitTemperature.celsius)

Measurementを使うことで、例えば、華氏での値が欲しければ、

摂氏から華氏への変換例
temperature.converted(to: UnitTemperature.fahrenheit)
とすることで、華氏での値を取得することができます。

ここまでは、非常にわかりやすいです。




Step2: UIレイアウト

UIは、タイトルを一番上に配置して、その後、TextFieldを縦に並べることとしました。(摂氏、華氏だけでなく、ケルビン/Kでも表示してみました)

SwiftUIでのUI定義

struct ContentView: View {
    @State private var temp = Measurement(value: 25.6, unit: UnitTemperature.celsius)
    var body: some View {
        VStack(alignment: .center) {
            Text("Temperature Converter")
            TextField("Temp in Celsius", value: $temp, formatter: ????)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            TextField("Temp in Celsius", value: $temp, formatter: ????)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            TextField("Temp in kelvin", value: $temp, formatter: ????)
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
    .padding(50)
    }
}

TextFieldを配置するところに難しさはありません。

ただ、TextFieldで値を表示するためのformatterでハマりました。

MeasurementFormatterを使うのが定石だと思うのですが、単位を指定して表示させるMeasurementFormatterがありません・・・😓

Localeに合わせて自動で表示してくれるのは非常に便利ですが、こういう時に困ることがわかりました。

Step3: MeasurementFormatterを拡張

なければ、作ってしまえということで以下のようなMeasurementForamtterの拡張版を作りました。表示する単位を指定することができます。

# 温度に使用するUnitTemperatureをうまく、Generics的に扱えませんでした・・・orz 今後の課題とします。

以下のようなGerericsを使ったクラスを作成しました。

# さらに、元々のUnitを使ったMeasurementを返すように改善しました。

MeasurementFormatterWithDisplayUnit

class MeasurementFormatterWithDisplayUnit : MeasurementFormatter{
    let originalUnit: T
    let displayUnit: T

    init(originalUnit: T, displayUnit:T) {
        self.originalUnit = originalUnit
        self.displayUnit = displayUnit
        super.init()
    }

    required init?(coder: NSCoder) {
        // get local unit, argument does not have any meaning
        self.originalUnit = T.init(coder: coder)!
        self.displayUnit = T.init(coder: coder)!
        super.init(coder: coder)
    }

    override func string(for obj: Any?) -> String? {
        guard let measurement = obj as? Measurement else { return nil }
        let anotherFormatter = MeasurementFormatter()
        anotherFormatter.unitOptions = .providedUnit
        return anotherFormatter.string(from: measurement.converted(to: self.displayUnit))
    }

    override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer?) -> Bool {
        guard let opt1 = obj else { return false }
        // remove symbol (i.e. pick up only numbers
        let numberExpressionCharacterSet = CharacterSet(charactersIn: "0123456789,.")
        let numberString = string.components(separatedBy: numberExpressionCharacterSet.inverted).joined()
        guard let number = self.numberFormatter.number(from: numberString) else { return false }
        let displayMeasurement = Measurement(value: number.doubleValue, unit: self.displayUnit)
        opt1.pointee = (Measurement(value: displayMeasurement.converted(to: self.originalUnit).value, unit: self.originalUnit) as AnyObject)
        return true
    }
}

記録のために、以下のGenericsでないバージョンも残しておきます。対応Dimensionごとに作る必要があります。

# クイックハック感が強いですが、その通りです。

MeasurementFormatterを継承したクラス定義

class MeasurementFormatterForTemperature : MeasurementFormatter{
    static let NumberExpressionCharacterSet = CharacterSet(charactersIn: "0123456789,.")
    let displayUnit: UnitTemperature
    
    init(displayUnit:UnitTemperature) {
        self.displayUnit = displayUnit
        super.init()
    }
    
    required init?(coder: NSCoder) {
        // get local unit, argument does not make any meaning
        self.displayUnit = Measurement(value: 0.0, unit: .celsius).unit
        super.init(coder: coder)
    }
    
    override func string(for obj: Any?) -> String? {
        guard let measurement = obj as? Measurement else { return nil }
        let anotherFormatter = MeasurementFormatter()
        anotherFormatter.unitOptions = .providedUnit
        return anotherFormatter.string(from: measurement.converted(to: self.displayUnit))
    }
    
    override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer?) -> Bool {
        guard let opt1 = obj else { return false }
        // remove symbol (i.e. pick up only numbers
        let numberString = string.components(separatedBy: MeasurementFormatterForTemperature.NumberExpressionCharacterSet.inverted).joined()
        guard let number = self.numberFormatter.number(from: numberString) else { return false }
        opt1.pointee = (Measurement(value: number.doubleValue, unit: UnitTemperature.celsius) as AnyObject)
        return true
    }
}




Step4: 完成したUnitConverter

最終コード

struct ContentView: View {
    @State private var temp = Measurement(value: 25.6, unit: UnitTemperature.celsius)
    var body: some View {
        VStack(alignment: .center) {
            Text("Temp. Converter")
                .font(.title)
            TextField("Temp in Celsius", value: $temp, formatter: MeasurementFormatterWithDisplayUnit(originalUnit: UnitTemperature.celsius, displayUnit: UnitTemperature.celsius))
                .textFieldStyle(RoundedBorderTextFieldStyle())
            TextField("Temp in Celsius", value: $temp, formatter: MeasurementFormatterWithDisplayUnit(originalUnit: UnitTemperature.celsius, displayUnit: UnitTemperature.fahrenheit))
                .textFieldStyle(RoundedBorderTextFieldStyle())
            TextField("Temp in kelvin", value: $temp, formatter: MeasurementFormatterWithDisplayUnit(originalUnit: UnitTemperature.celsius, displayUnit: UnitTemperature.kelvin))
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
    .padding(50)
    }
}

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




Step5: まとめ

当初、UnitConverterが10行くらいで作れます とかになると思いましたが、変換そのものではなく、表示でハマりました。

ただ、このアプリがあくまで練習用のアプリということでの特殊なシチュエーションな気もします。

説明は以上です。

コメントを残す

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