FractionalQuantityPicker.swift 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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. body(availableWidth: availableWidth)
  89. case .independent:
  90. GeometryReader { geometry in
  91. HStack {
  92. Spacer()
  93. self.body(availableWidth: geometry.size.width)
  94. Spacer()
  95. }
  96. }
  97. .frame(height: 216)
  98. }
  99. }
  100. func body(availableWidth: CGFloat) -> some View {
  101. HStack(spacing: 0) {
  102. QuantityPicker(
  103. value: $whole.withUnit(unit),
  104. unit: unit,
  105. selectableValues: selectableWholeValues,
  106. formatter: Self.wholeFormatter,
  107. isUnitLabelVisible: false,
  108. colorForValue: colorForWhole
  109. )
  110. .frame(width: availableWidth / 3)
  111. .overlay(
  112. Text(separator)
  113. .foregroundColor(Color(.secondaryLabel))
  114. .offset(x: spacing + separatorWidth),
  115. alignment: .trailing
  116. )
  117. .padding(.leading, usageContext == .independent ? unitLabelWidth + spacing : 0)
  118. .padding(.trailing, spacing + separatorWidth + spacing)
  119. .clipped()
  120. .compositingGroup()
  121. QuantityPicker(
  122. value: $fraction.withUnit(unit),
  123. unit: unit,
  124. selectableValues: fractionalValuesByWhole[whole] ?? [0.0],
  125. formatter: fractionalFormatter,
  126. colorForValue: colorForFraction
  127. )
  128. .frame(width: availableWidth / 3)
  129. .padding(.trailing, spacing + unitLabelWidth)
  130. .clipped()
  131. .compositingGroup()
  132. }
  133. }
  134. private func colorForWhole(_ whole: Double) -> Color {
  135. assert(whole.whole == whole)
  136. let fractionIfWholeSelected = Self.matchingFraction(for: fraction, from: fractionalValuesByWhole[whole] ?? [0.0])
  137. let valueIfWholeSelected = whole + fractionIfWholeSelected
  138. let quantityIfWholeSelected = HKQuantity(unit: unit, doubleValue: valueIfWholeSelected)
  139. return guardrail.color(for: quantityIfWholeSelected, guidanceColors: guidanceColors)
  140. }
  141. private func colorForFraction(_ fraction: Double) -> Color {
  142. assert(fraction.fraction == fraction)
  143. let valueIfFractionSelected = whole + fraction
  144. let quantityIfFractionSelected = HKQuantity(unit: unit, doubleValue: valueIfFractionSelected)
  145. return guardrail.color(for: quantityIfFractionSelected, guidanceColors: guidanceColors)
  146. }
  147. private var fractionalFormatter: NumberFormatter {
  148. // Mutate the shared instance to avoid extra allocations.
  149. Self.fractionalFormatter.minimumFractionDigits = (fractionalValuesByWhole[whole] ?? [0.0])
  150. .lazy
  151. .map { Decimal($0) }
  152. .deltaScale(boundedBy: Self.maximumSupportedPrecision)
  153. return Self.fractionalFormatter
  154. }
  155. var separator: String { "." }
  156. var separatorWidth: CGFloat {
  157. let attributedSeparator = NSAttributedString(
  158. string: separator,
  159. attributes: [.font: UIFont.preferredFont(forTextStyle: .body)]
  160. )
  161. return attributedSeparator.size().width
  162. }
  163. var spacing: CGFloat { 4 }
  164. var unitLabelWidth: CGFloat {
  165. let attributedUnitString = NSAttributedString(
  166. string: unit.shortLocalizedUnitString(),
  167. attributes: [.font: UIFont.preferredFont(forTextStyle: .body)]
  168. )
  169. return attributedUnitString.size().width
  170. }
  171. }
  172. fileprivate extension FloatingPoint {
  173. var whole: Self { modf(self).0 }
  174. var fraction: Self { modf(self).1 }
  175. }
  176. fileprivate extension Decimal {
  177. func rounded(toPlaces scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .plain) -> Decimal {
  178. var result = Decimal()
  179. var localCopy = self
  180. NSDecimalRound(&result, &localCopy, scale, roundingMode)
  181. return result
  182. }
  183. }
  184. fileprivate extension Collection where Element == Decimal {
  185. /// Returns the maximum number of decimal places necessary to meaningfully distinguish between adjacent values.
  186. /// - Precondition: The collection is sorted in ascending order.
  187. func deltaScale(boundedBy maxScale: Int) -> Int {
  188. let roundedToMaxScale = lazy.map { $0.rounded(toPlaces: maxScale) }
  189. guard let minDelta = roundedToMaxScale.adjacentPairs().map(-).map(abs).min() else {
  190. return 0
  191. }
  192. return abs(Swift.min(minDelta.exponent, 0))
  193. }
  194. }