// // FractionalQuantityPicker.swift // LoopKitUI // // Created by Michael Pangburn on 5/18/20. // Copyright © 2020 LoopKit Authors. All rights reserved. // import SwiftUI import HealthKit import LoopKit /// Enables selecting the whole and fractional parts of an HKQuantity value in independent pickers. public struct FractionalQuantityPicker: View { public enum UsageContext: Equatable { /// This picker is one component of a larger multi-component picker (e.g. a schedule item picker). case component(availableWidth: CGFloat) /// This picker operates independently. case independent } @Environment(\.guidanceColors) var guidanceColors @Binding var whole: Double @Binding var fraction: Double var unit: HKUnit var guardrail: Guardrail var selectableWholeValues: [Double] var fractionalValuesByWhole: [Double: [Double]] var usageContext: UsageContext /// The maximum number of decimal places supported by the picker. private static var maximumSupportedPrecision: Int { 3 } private static let wholeFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.minimumIntegerDigits = 1 formatter.maximumFractionDigits = 0 return formatter }() private static let fractionalFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.decimalSeparator = "" formatter.maximumIntegerDigits = 0 return formatter }() public init( value: Binding, unit: HKUnit, guardrail: Guardrail, selectableValues: [Double], usageContext: UsageContext = .independent ) { let doubleValue = value.doubleValue(for: unit) let (selectableWholeValues, fractionalValuesByWhole): ([Double], [Double: [Double]]) = selectableValues.reduce(into: ([], [:])) { pair, selectableValue in let whole = selectableValue.whole if pair.0.last != whole { pair.0.append(whole) } pair.1[whole, default: []].append(unit.roundForPicker(value: selectableValue.fraction)) } self._whole = Binding( get: { doubleValue.wrappedValue.whole }, set: { newWholeValue in let newFractionValue = Self.matchingFraction(for: doubleValue.wrappedValue.fraction, from: fractionalValuesByWhole[newWholeValue] ?? [0.0]) let newDoubleValue = newWholeValue + newFractionValue let maxValue = guardrail.absoluteBounds.upperBound.doubleValue(for: unit) doubleValue.wrappedValue = min(newDoubleValue, maxValue) } ) self._fraction = Binding( get: { doubleValue.wrappedValue.fraction.roundedToNearest(of: fractionalValuesByWhole[doubleValue.wrappedValue.whole] ?? [0.0]) }, set: { newFractionValue in let newDoubleValue = doubleValue.wrappedValue.whole + newFractionValue let minValue = guardrail.absoluteBounds.lowerBound.doubleValue(for: unit) doubleValue.wrappedValue = max(newDoubleValue, minValue) } ) self.unit = unit self.guardrail = guardrail self.selectableWholeValues = selectableWholeValues self.fractionalValuesByWhole = fractionalValuesByWhole self.usageContext = usageContext } private static func matchingFraction( for currentFraction: Double, from supportedFractionValues: [Double] ) -> Double { currentFraction.matchingOrTruncatedValue(from: supportedFractionValues, withinDecimalPlaces: Self.maximumSupportedPrecision) } public var body: some View { switch usageContext { case .component(availableWidth: let availableWidth): return AnyView(body(availableWidth: availableWidth)) case .independent: return AnyView( GeometryReader { geometry in HStack { Spacer() self.body(availableWidth: geometry.size.width) Spacer() } } .frame(height: 216) ) } } func body(availableWidth: CGFloat) -> some View { HStack(spacing: 0) { QuantityPicker( value: $whole.withUnit(unit), unit: unit, selectableValues: selectableWholeValues, formatter: Self.wholeFormatter, isUnitLabelVisible: false, colorForValue: colorForWhole ) .frame(width: availableWidth / 3) .overlay( Text(separator) .foregroundColor(Color(.secondaryLabel)) .offset(x: spacing + separatorWidth), alignment: .trailing ) .padding(.leading, usageContext == .independent ? unitLabelWidth + spacing : 0) .padding(.trailing, spacing + separatorWidth + spacing) .clipped() .compositingGroup() QuantityPicker( value: $fraction.withUnit(unit), unit: unit, selectableValues: fractionalValuesByWhole[whole] ?? [0.0], formatter: fractionalFormatter, colorForValue: colorForFraction ) .frame(width: availableWidth / 3) .padding(.trailing, spacing + unitLabelWidth) .clipped() .compositingGroup() } } private func colorForWhole(_ whole: Double) -> Color { assert(whole.whole == whole) let fractionIfWholeSelected = Self.matchingFraction(for: fraction, from: fractionalValuesByWhole[whole] ?? [0.0]) let valueIfWholeSelected = whole + fractionIfWholeSelected let quantityIfWholeSelected = HKQuantity(unit: unit, doubleValue: valueIfWholeSelected) return guardrail.color(for: quantityIfWholeSelected, guidanceColors: guidanceColors) } private func colorForFraction(_ fraction: Double) -> Color { assert(fraction.fraction == fraction) let valueIfFractionSelected = whole + fraction let quantityIfFractionSelected = HKQuantity(unit: unit, doubleValue: valueIfFractionSelected) return guardrail.color(for: quantityIfFractionSelected, guidanceColors: guidanceColors) } private var fractionalFormatter: NumberFormatter { // Mutate the shared instance to avoid extra allocations. Self.fractionalFormatter.minimumFractionDigits = (fractionalValuesByWhole[whole] ?? [0.0]) .lazy .map { Decimal($0) } .deltaScale(boundedBy: Self.maximumSupportedPrecision) return Self.fractionalFormatter } var separator: String { "." } var separatorWidth: CGFloat { let attributedSeparator = NSAttributedString( string: separator, attributes: [.font: UIFont.preferredFont(forTextStyle: .body)] ) return attributedSeparator.size().width } var spacing: CGFloat { 4 } var unitLabelWidth: CGFloat { let attributedUnitString = NSAttributedString( string: unit.shortLocalizedUnitString(), attributes: [.font: UIFont.preferredFont(forTextStyle: .body)] ) return attributedUnitString.size().width } } fileprivate extension FloatingPoint { var whole: Self { modf(self).0 } var fraction: Self { modf(self).1 } } fileprivate extension Decimal { func rounded(toPlaces scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .plain) -> Decimal { var result = Decimal() var localCopy = self NSDecimalRound(&result, &localCopy, scale, roundingMode) return result } } fileprivate extension Collection where Element == Decimal { /// Returns the maximum number of decimal places necessary to meaningfully distinguish between adjacent values. /// - Precondition: The collection is sorted in ascending order. func deltaScale(boundedBy maxScale: Int) -> Int { let roundedToMaxScale = lazy.map { $0.rounded(toPlaces: maxScale) } guard let maxDelta = roundedToMaxScale.adjacentPairs().map(-).map(abs).max() else { return 0 } return abs(Swift.min(maxDelta.exponent, 0)) } }