OIMlayout.swift (17403B)
1 /* 2 * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Marc Stibane 7 */ 8 import SwiftUI 9 import SymLog 10 import os.log 11 12 struct OIMid: LayoutValueKey { 13 static let defaultValue = 0 14 } 15 struct OIMmorph: LayoutValueKey { 16 static let defaultValue = 0 17 } 18 struct OIMvalue: LayoutValueKey { 19 static let defaultValue: UInt64 = 0 20 } 21 struct OIMflippedval: LayoutValueKey { 22 static let defaultValue: UInt64 = 0 23 } 24 struct OIMfundState: LayoutValueKey { 25 static let defaultValue: FundState = .idle 26 } 27 28 @available(iOS 16.4, *) 29 extension View { 30 func oimID(_ value: Int) -> some View { 31 self.layoutValue(key: OIMid.self, value: value) 32 } 33 func oimMorph(_ value: Int) -> some View { 34 self.layoutValue(key: OIMmorph.self, value: value) 35 } 36 func oimValue(_ value: UInt64) -> some View { 37 self.layoutValue(key: OIMvalue.self, value: value) 38 } 39 func oimFlippedVal(_ value: UInt64) -> some View { 40 self.layoutValue(key: OIMflippedval.self, value: value) 41 } 42 func oimFundState(_ value: FundState) -> some View { 43 self.layoutValue(key: OIMfundState.self, value: value) 44 } 45 } 46 47 @available(iOS 16.4, *) 48 extension LayoutSubview { 49 var oimID: Int { self[OIMid.self] } 50 var oimValue: UInt64 { self[OIMvalue.self] } 51 var oimFlippedVal: UInt64 { self[OIMflippedval.self] } 52 var oimFundState: FundState { self[OIMfundState.self] } 53 } 54 55 // MARK: - 56 // renders a stack of funds (banknotes, coins) 57 @available(iOS 16.4, *) 58 struct OIMlayoutView: View { 59 private let symLog = SymLogV(0) 60 // let logger = Logger(subsystem: "net.taler.gnu", category: "OIMlayoutView") 61 let stack: CallStack 62 let cash: OIMcash 63 let funds: OIMfunds 64 @Binding var amountVal: UInt64 65 let canEdit: Bool 66 67 @EnvironmentObject private var wrapper: NamespaceWrapper 68 @State private var checkStacks: UInt64 = 0 69 70 func endFlying(_ index: Int, after delay: Double) { 71 var fund = funds[index] 72 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 73 symLog.log("*** end flying:\(fund.value) \(fund.id)") 74 withAnimation(.move1) { 75 fund.state = .idle // move onto stack 76 cash.updateFund(fund) 77 } 78 if checkStacks > fund.value || checkStacks == 0 { 79 symLog.log("*** checkStacks:\(fund.value)") 80 checkStacks = fund.value 81 } 82 } 83 } 84 85 func startFlying(fundID: Int, fromChest: Bool = false) { 86 if let index = funds.firstIndex(where: { $0.id == fundID }) { 87 var fund = funds[index] 88 symLog.log("*** start flying:\(fund.value) \(fundID)") 89 if fromChest && fund.delay > 0 { 90 DispatchQueue.main.asyncAfter(deadline: .now() + fund.delay) { 91 withAnimation(.move1) { 92 fund.state = .curve 93 cash.updateFund(fund) 94 } 95 endFlying(index, after: flyDelay / 3) 96 } 97 } else { 98 endFlying(index, after: flyDelay) 99 } 100 withAnimation(.move1) { 101 fund.state = fromChest ? .reveal : .isFlying // switch off matching 102 cash.updateFund(fund) 103 } 104 } 105 } 106 107 func flyBack(fundID: Int) { 108 if let index = funds.firstIndex(where: { $0.id == fundID }) { 109 var fund = funds[index] 110 symLog.log("*** fly back:\(fund.value) \(fundID)") 111 withAnimation(.move1) { 112 fund.state = .removing 113 cash.updateFund(fund) 114 } 115 DispatchQueue.main.asyncAfter(deadline: .now() + flyDelay) { 116 symLog.log("*** remove:\(fund.value) \(fundID)") 117 withAnimation(.move1) { 118 cash.removeCash(id: fundID, value: fund.value) 119 } 120 } 121 } 122 } 123 124 var body: some View { 125 OIMlayout { 126 ForEach(funds) { fund in 127 let value = fund.value 128 let fundID = fund.id 129 let fundState = fund.state 130 let shouldFly = fundState.shouldFly 131 // let willMutate = fundState == .arriving || fundState == .mutating 132 OIMcurrencyButton(stack: stack.push(), 133 fund: fund, 134 currency: cash.currency, 135 availableVal: value, 136 canEdit: canEdit, 137 isDrawer: false, 138 pct: shouldFly ? 0.0 : 1.0 139 ) { // remove on button press 140 if amountVal >= fund.value { 141 amountVal -= fund.value 142 } else { 143 symLog.log(" ❗️Yikes - trying to subtract \(fund.value) from amount \(amountVal)") 144 amountVal = 0 145 } 146 flyBack(fundID: fundID) 147 } 148 .zIndex(Double(fundID)) 149 .id(fundID) 150 .oimValue(value) 151 .oimFlippedVal(fund.flippedVal ?? 0) 152 .oimID(fundID) 153 .oimFundState(fundState) 154 .matchedGeometryEffect(id: fund.targetID, in: wrapper.namespace, isSource: false) 155 .onAppear { 156 if fundState.chestOpening { 157 startFlying(fundID: fundID, fromChest: true) 158 } else if fundState.shouldFly { 159 startFlying(fundID: fundID) 160 } else { 161 // print(" ->OIMlayout ForEach fund.onAppear ignore \(value), \(fundID)") 162 } 163 } 164 } 165 } 166 .onChange(of: checkStacks) { value in 167 if value > 0 { 168 symLog.log("*** onChange(of: checkStacks) \(value)") 169 var firstCheck = value 170 if let moreThan4 = cash.checkStacks(first: firstCheck) { 171 firstCheck = 0 172 cash.compactStacks(moreThan4) 173 } 174 checkStacks = 0 175 } 176 } 177 } 178 } 179 // MARK: - 180 @available(iOS 16.4, *) 181 struct OIMlayout: Layout { 182 183 func sortByValue(_ subviews: LayoutSubviews) -> [LayoutSubview] { 184 // sorts DESCENDING - we render the denominations top-down 185 subviews.sorted(by: { 186 let state0 = $0.oimFundState 187 let state1 = $1.oimFundState 188 let value0 = $0.oimValue 189 let value1 = $1.oimValue 190 let flipV0 = $0.oimFlippedVal 191 let flipV1 = $1.oimFlippedVal 192 let id0 = $0.oimID 193 let id1 = $1.oimID 194 let isSameVal = value0 == value1 195 let id0isSmaller = id0 < id1 196 let id0isBigger = id1 < id0 197 198 let isMorphing1 = state1 == .position 199 || state1 == .morphing 200 || state1 == .arriving 201 || state1 == .mutating 202 if isMorphing1 { 203 if flipV1 > 0 { 204 let firstIsBigger = value0 > flipV1 205 let sameFlip = value0 == flipV1 206 207 let sameFlip_ID0smaller = sameFlip && id0isSmaller 208 let result = firstIsBigger || sameFlip_ID0smaller 209 return result 210 } 211 } 212 213 let isMorphing0 = state0 == .position 214 || state0 == .morphing 215 || state0 == .arriving 216 || state0 == .mutating 217 if isMorphing0 { 218 if flipV0 > 0 { 219 let firstIsBigger = flipV0 > value1 220 let sameFlip = value1 == flipV0 221 222 let sameFlip_ID0smaller = sameFlip && id0isSmaller 223 let result = firstIsBigger || sameFlip_ID0smaller 224 return result 225 } 226 } 227 let firstIsBigger = value0 > value1 228 // but keep the order of the IDs for identical values 229 let sameVal_ID0smaller = isSameVal && id0isSmaller 230 let result = firstIsBigger || sameVal_ID0smaller 231 return result 232 }) 233 } 234 235 func computeSpaces(_ sorted: [LayoutSubview], _ max: Int = MAXSTACK) -> [Int] { 236 /// same algorithm as OIMcash.checkStacks 237 /// returns array of stackIndexes 238 /// 0 ==> next view starts a new stack 239 /// 1..3 ==> next view is on the stack 240 /// -1 ==> view is invisible, will be deleted 241 var spaces: [Int] = [] 242 243 var stackIndex = 0 244 var lastValue: UInt64 = 0 245 for (index, subview) in sorted.enumerated() { 246 let value = subview.oimValue 247 let state = subview.oimFundState 248 let id = subview.oimID 249 // let flippedVal = subview.oimFlippedVal ?? 0 250 var ignore = 0 251 if state.shouldFly || state.isFlying // flying can not go on the stack 252 || state.position // nor can the position where we morph... 253 || state.morphing { // ...or the morphing fund 254 // we always morph out from the end, thus there will be no more idling funds with the same value 255 lastValue = 0 // let the next subview... 256 stackIndex = 0 // ...start a new stack 257 } else if state.moving || state.hiding { 258 ignore = -1 259 } else if lastValue != value { // different value? 260 lastValue = value // save this value for the next subview 261 stackIndex = 0 // start a new stack 262 } else { 263 stackIndex += 1 // Yay, we found one to add to this stack 264 if stackIndex == max { // max 4 subviews per stack 265 stackIndex = 0 // stack is full, start a new one but keep the value 266 } } 267 if index > 0 { 268 spaces.append(ignore == 0 ? stackIndex : ignore) 269 } 270 } 271 return spaces 272 } 273 274 func accumulate(spaces: [Int], _ viewSizes: [CGSize], spacing: CGFloat, xOffset: CGFloat) -> CGFloat { 275 var accumulatedSpaces: CGFloat = .zero 276 for (idx, stackIndex) in spaces.enumerated() { 277 if stackIndex > 0 { // next view is on the stack, add xOffset, subtract width 278 accumulatedSpaces += (xOffset - viewSizes[idx].width) 279 } else if stackIndex == 0 { // start a new stack, add spacing 280 accumulatedSpaces += spacing 281 } // else ignore view 282 } 283 return accumulatedSpaces 284 } 285 286 func spacing(for proposal: ProposedViewSize) -> (CGFloat, CGPoint) { 287 switch proposal { 288 case .infinity: // max size: just place all views in one row 289 (10, CGPoint(x: 10, y: 0)) 290 case .zero: // minimum size: tight spacing 291 (8, CGPoint(x: 8, y: 20)) 292 default: // TODO: compute offset - in the meantime => ideal size 293 // case .unspecified: // => ideal size 294 (10, CGPoint(x: 16, y: 16)) 295 } 296 } 297 298 func accumulate(views: [LayoutSubview], sizes: [CGSize]) -> CGFloat { 299 var result = CGFloat.zero 300 for (index, subview) in views.enumerated() { 301 let state = subview.oimFundState 302 if state != .moving && state != .hiding { 303 let size = sizes[index] 304 result += size.width 305 } 306 } 307 return result 308 } 309 310 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { 311 guard subviews.count > 0 else { return CGSize.zero } 312 let (spacing, offset) = spacing(for: proposal) 313 let sorted = sortByValue(subviews) 314 let viewSizes = sorted.map { $0.sizeThatFits(proposal) } // <- THIS takes time... 315 let maxHeight = viewSizes.reduce(0) { max($0, $1.height) } // get the max height 316 let viewWidths = accumulate(views: sorted, sizes: viewSizes) // add up all widths 317 318 let spaces = computeSpaces(sorted) 319 let maxStackIndex = spaces.reduce(0) { max($0, $1) } 320 321 var accumulatedSpaces: CGFloat = .zero 322 var stackHeight: CGFloat = .zero 323 switch proposal { 324 case .infinity: // max size: just place all views in one row 325 // print("MAX:", proposal) 326 accumulatedSpaces = spacing * CGFloat(sorted.count - 1) 327 break 328 case .zero: // minimum size: 329 // print("min:", proposal) 330 // all stacks with offset x = 8, y = 20 331 stackHeight = offset.y * CGFloat(maxStackIndex) 332 accumulatedSpaces = accumulate(spaces: spaces, viewSizes, spacing: spacing, xOffset: offset.x) 333 break 334 case .unspecified: // => ideal size 335 // print("Ideal:", proposal) 336 stackHeight = offset.y * CGFloat(maxStackIndex) 337 accumulatedSpaces = accumulate(spaces: spaces, viewSizes, spacing: spacing, xOffset: offset.x) 338 break 339 default: 340 if var spaceToFill = proposal.width { 341 if var heightToFill = proposal.height { 342 // print("Yikes❗️ width + height:", spaceToFill, heightToFill) 343 // // TODO: compute the inner-stack offsets 344 // spaceToFill -= viewWidths // might be negative 345 } else { // unspecified 346 // print("Yikes❗️ width only:", spaceToFill) 347 } 348 } else if var heightToFill = proposal.height { 349 // print("Yikes❗️ height only:", heightToFill) 350 } else { // unspecified 351 // print("Yikes❗️ should NEVER happen:", proposal) 352 } 353 354 // while inner-stack offsets is not yet implemented, return ideal size 355 stackHeight = offset.y * CGFloat(maxStackIndex) 356 accumulatedSpaces = accumulate(spaces: spaces, viewSizes, spacing: spacing, xOffset: offset.x) 357 break 358 } 359 let result = CGSize(width: viewWidths + accumulatedSpaces, 360 height: maxHeight + stackHeight) 361 // print(" ***sizeThatFits:", result) 362 return result 363 } 364 365 func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { 366 let (spacing, offset) = spacing(for: proposal) 367 let sorted = sortByValue(subviews) 368 let spaces = computeSpaces(sorted) 369 let isMax = proposal == .infinity 370 371 var pt = CGPoint(x: bounds.minX, y: bounds.minY) 372 var morphPt = CGPoint.zero 373 for idx in sorted.indices { 374 let subview = sorted[idx] 375 let id = subview.oimID 376 let state = subview.oimFundState 377 if state.position || state.morphing { 378 // if morphPt != pt { print("set morphPt \(morphPt) to ク\(subview.oimValue),\(subview.oimID) at", pt) } 379 morphPt = pt 380 } 381 subview.place(at: pt, anchor: .topLeading, proposal: proposal) 382 if state.moving || state.hiding { 383 if morphPt != CGPoint.zero { 384 // print("morphing ク\(subview.oimValue) \(subview.oimID) \(state) from \(pt) to", morphPt) 385 subview.place(at: morphPt, anchor: .topLeading, proposal: proposal) 386 } else { 387 // symLog.log("Yikes: no morphing point for ク", subview.oimValue, subview.oimID, state) 388 } 389 // } else if state != .morphingIn && state != .morphedIn { 390 // print("placing ク\(subview.oimValue),\(subview.oimID) at", pt) 391 } 392 393 if idx < sorted.count - 1 { 394 let space = spaces[idx] 395 let nextView = sorted[idx+1] 396 let state = nextView.oimFundState 397 if state != .moving && state != .hiding { 398 if space == 0 || isMax { 399 // print("Start new stack for ク", nextView.oimValue, nextView.oimID, state) 400 let width = subview.sizeThatFits(proposal).width 401 pt.x += width + spacing 402 pt.y = bounds.minY 403 } else if space > 0 { 404 // print(" place on stack ク", nextView.oimValue, nextView.oimID, state) 405 pt.x += offset.x 406 pt.y += offset.y 407 // } else { 408 // print("ignore ク", nextView.oimValue, nextView.oimID, state) 409 } 410 } // place subviews morphing out at position of view morphing in 411 } 412 } 413 } 414 }