FractionalQuantityPicker.swift 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. //
  2. // FractionalQuantityPicker.swift
  3. // LoopKitUI
  4. //
  5. // Created by Michael Pangburn on 5/18/20.
  6. // Copyright © 2020 LoopKit Authors. All rights reserved.
  7. //
  8. import SwiftUI
  9. import HealthKit
  10. import LoopKit
  11. /// Enables selecting the whole and fractional parts of an HKQuantity value in independent pickers.
  12. public struct FractionalQuantityPicker: View {
  13. public enum UsageContext: Equatable {
  14. /// This picker is one component of a larger multi-component picker (e.g. a schedule item picker).
  15. case component(availableWidth: CGFloat)
  16. /// This picker operates independently.
  17. case independent
  18. }
  19. @Environment(\.guidanceColors) var guidanceColors
  20. @Binding var whole: Double
  21. @Binding var fraction: Double
  22. var unit: HKUnit
  23. var guardrail: Guardrail<HKQuantity>
  24. var selectableWholeValues: [Double]
  25. var fractionalValuesByWhole: [Double: [Double]]
  26. var usageContext: UsageContext
  27. /// The maximum number of decimal places supported by the picker.
  28. private static var maximumSupportedPrecision: Int { 3 }
  29. private static let wholeFormatter: NumberFormatter = {
  30. let formatter = NumberFormatter()
  31. formatter.minimumIntegerDigits = 1
  32. formatter.maximumFractionDigits = 0
  33. return formatter
  34. }()
  35. private static let fractionalFormatter: NumberFormatter = {
  36. let formatter = NumberFormatter()
  37. formatter.decimalSeparator = ""
  38. formatter.maximumIntegerDigits = 0
  39. return formatter
  40. }()
  41. public init(
  42. value: Binding<HKQuantity>,
  43. unit: HKUnit,
  44. guardrail: Guardrail<HKQuantity>,
  45. selectableValues: [Double],
  46. usageContext: UsageContext = .independent
  47. ) {
  48. let doubleValue = value.doubleValue(for: unit)
  49. let (selectableWholeValues, fractionalValuesByWhole): ([Double], [Double: [Double]]) = selectableValues.reduce(into: ([], [:])) { pair, selectableValue in
  50. let whole = selectableValue.whole
  51. if pair.0.last != whole {
  52. pair.0.append(whole)
  53. }
  54. pair.1[whole, default: []].append(unit.roundForPicker(value: selectableValue.fraction))
  55. }
  56. self._whole = Binding(
  57. get: { doubleValue.wrappedValue.whole },
  58. set: { newWholeValue in
  59. let newFractionValue = Self.matchingFraction(for: doubleValue.wrappedValue.fraction, from: fractionalValuesByWhole[newWholeValue] ?? [0.0])
  60. let newDoubleValue = newWholeValue + newFractionValue
  61. let maxValue = guardrail.absoluteBounds.upperBound.doubleValue(for: unit)
  62. doubleValue.wrappedValue = min(newDoubleValue, maxValue)
  63. }
  64. )
  65. self._fraction = Binding(
  66. get: { doubleValue.wrappedValue.fraction.roundedToNearest(of: fractionalValuesByWhole[doubleValue.wrappedValue.whole] ?? [0.0]) },
  67. set: { newFractionValue in
  68. let newDoubleValue = doubleValue.wrappedValue.whole + newFractionValue
  69. let minValue = guardrail.absoluteBounds.lowerBound.doubleValue(for: unit)
  70. doubleValue.wrappedValue = max(newDoubleValue, minValue)
  71. }
  72. )
  73. self.unit = unit
  74. self.guardrail = guardrail
  75. self.selectableWholeValues = selectableWholeValues
  76. self.fractionalValuesByWhole = fractionalValuesByWhole
  77. self.usageContext = usageContext
  78. }
  79. private static func matchingFraction(
  80. for currentFraction: Double,
  81. from supportedFractionValues: [Double]
  82. ) -> Double {
  83. currentFraction.matchingOrTruncatedValue(from: supportedFractionValues, withinDecimalPlaces: Self.maximumSupportedPrecision)
  84. }
  85. public var body: some View {
  86. switch usageContext {
  87. case .component(availableWidth: let availableWidth):
  88. return AnyView(body(availableWidth: availableWidth))
  89. case .independent:
  90. return AnyView(
  91. GeometryReader { geometry in
  92. HStack {
  93. Spacer()
  94. self.body(availableWidth: geometry.size.width)
  95. Spacer()
  96. }
  97. }
  98. .frame(height: 216)
  99. )
  100. }
  101. }
  102. func body(availableWidth: CGFloat) -> some View {
  103. HStack(spacing: 0) {
  104. QuantityPicker(
  105. value: $whole.withUnit(unit),
  106. unit: unit,
  107. selectableValues: selectableWholeValues,
  108. formatter: Self.wholeFormatter,
  109. isUnitLabelVisible: false,
  110. colorForValue: colorForWhole
  111. )
  112. .frame(width: availableWidth / 3)
  113. .overlay(
  114. Text(separator)
  115. .foregroundColor(Color(.secondaryLabel))
  116. .offset(x: spacing + separatorWidth),
  117. alignment: .trailing
  118. )
  119. .padding(.leading, usageContext == .independent ? unitLabelWidth + spacing : 0)
  120. .padding(.trailing, spacing + separatorWidth + spacing)
  121. .clipped()
  122. .compositingGroup()
  123. QuantityPicker(
  124. value: $fraction.withUnit(unit),
  125. unit: unit,
  126. selectableValues: fractionalValuesByWhole[whole] ?? [0.0],
  127. formatter: fractionalFormatter,
  128. colorForValue: colorForFraction
  129. )
  130. .frame(width: availableWidth / 3)
  131. .padding(.trailing, spacing + unitLabelWidth)
  132. .clipped()
  133. .compositingGroup()
  134. }
  135. }
  136. private func colorForWhole(_ whole: Double) -> Color {
  137. assert(whole.whole == whole)
  138. let fractionIfWholeSelected = Self.matchingFraction(for: fraction, from: fractionalValuesByWhole[whole] ?? [0.0])
  139. let valueIfWholeSelected = whole + fractionIfWholeSelected
  140. let quantityIfWholeSelected = HKQuantity(unit: unit, doubleValue: valueIfWholeSelected)
  141. return guardrail.color(for: quantityIfWholeSelected, guidanceColors: guidanceColors)
  142. }
  143. private func colorForFraction(_ fraction: Double) -> Color {
  144. assert(fraction.fraction == fraction)
  145. let valueIfFractionSelected = whole + fraction
  146. let quantityIfFractionSelected = HKQuantity(unit: unit, doubleValue: valueIfFractionSelected)
  147. return guardrail.color(for: quantityIfFractionSelected, guidanceColors: guidanceColors)
  148. }
  149. private var fractionalFormatter: NumberFormatter {
  150. // Mutate the shared instance to avoid extra allocations.
  151. Self.fractionalFormatter.minimumFractionDigits = (fractionalValuesByWhole[whole] ?? [0.0])
  152. .lazy
  153. .map { Decimal($0) }
  154. .deltaScale(boundedBy: Self.maximumSupportedPrecision)
  155. return Self.fractionalFormatter
  156. }
  157. var separator: String { "." }
  158. var separatorWidth: CGFloat {
  159. let attributedSeparator = NSAttributedString(
  160. string: separator,
  161. attributes: [.font: UIFont.preferredFont(forTextStyle: .body)]
  162. )
  163. return attributedSeparator.size().width
  164. }
  165. var spacing: CGFloat { 4 }
  166. var unitLabelWidth: CGFloat {
  167. let attributedUnitString = NSAttributedString(
  168. string: unit.shortLocalizedUnitString(),
  169. attributes: [.font: UIFont.preferredFont(forTextStyle: .body)]
  170. )
  171. return attributedUnitString.size().width
  172. }
  173. }
  174. fileprivate extension FloatingPoint {
  175. var whole: Self { modf(self).0 }
  176. var fraction: Self { modf(self).1 }
  177. }
  178. fileprivate extension Decimal {
  179. func rounded(toPlaces scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .plain) -> Decimal {
  180. var result = Decimal()
  181. var localCopy = self
  182. NSDecimalRound(&result, &localCopy, scale, roundingMode)
  183. return result
  184. }
  185. }
  186. fileprivate extension Collection where Element == Decimal {
  187. /// Returns the maximum number of decimal places necessary to meaningfully distinguish between adjacent values.
  188. /// - Precondition: The collection is sorted in ascending order.
  189. func deltaScale(boundedBy maxScale: Int) -> Int {
  190. let roundedToMaxScale = lazy.map { $0.rounded(toPlaces: maxScale) }
  191. guard let maxDelta = roundedToMaxScale.adjacentPairs().map(-).map(abs).max() else {
  192. return 0
  193. }
  194. return abs(Swift.min(maxDelta.exponent, 0))
  195. }
  196. }