FractionalQuantityPicker.swift 8.4 KB

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