要約
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)
}
}
}
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)
}
}
}
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)
}
}
}
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)
}
}
}
まとめ
つかおう!Measurement!