taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit a18272cce61d595334685048e0f3d3ad197ffae0
parent 0c150692efef040a8a8e8e8d0a1d768de9b8db99
Author: Marc Stibane <marc@taler.net>
Date:   Fri, 18 Apr 2025 18:00:07 +0200

animations

Diffstat:
MTalerWallet1/Views/OIM/OIM15Views.swift | 12++++++------
MTalerWallet1/Views/OIM/OIMcash.swift | 206++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
MTalerWallet1/Views/OIM/OIMcurrencyButton.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
MTalerWallet1/Views/OIM/OIMcurrencyScroller.swift | 4++--
MTalerWallet1/Views/OIM/OIMlayout.swift | 267+++++++++++++++++++++++++++++++++++++++++++------------------------------------
MTalerWallet1/Views/OIM/OIMlineView.swift | 25+++++++++++++------------
6 files changed, 382 insertions(+), 218 deletions(-)

diff --git a/TalerWallet1/Views/OIM/OIM15Views.swift b/TalerWallet1/Views/OIM/OIM15Views.swift @@ -39,7 +39,7 @@ struct OIMnoteStackV: View { let xOffset = CGFloat(10 * index) let yOffset = CGFloat(20 * index) let _ = print("targetID \(targetID), flying \(flying)") - let fund = OIMfund(id: Int(value), value: value, state: .idle) + let fund = OIMfund(id: Int(value), state: .idle, value: value, flippedVal: nil) OIMcurrencyButton(stack: stack.push(), // value: value, fund: fund, @@ -53,7 +53,7 @@ struct OIMnoteStackV: View { .matchedGeometryEffect(id: targetID, in: wrapper.namespace, isSource: false) .onAppear { print("start flying \(targetID)") - withAnimation(.fly1) { + withAnimation(.move1) { flying = value // start flying } } @@ -93,7 +93,7 @@ struct OIMcoinStackV: View { let yOffset = offset * CGFloat(index) let xOffset = yOffset / 2 let _ = print("targetID \(targetID), flying \(flying)") - let fund = OIMfund(id: Int(value), value: value, state: .idle) // TODO: Flip coin + let fund = OIMfund(id: Int(value), state: .idle, value: value, flippedVal: nil) // TODO: Flip coin OIMcurrencyButton(stack: stack.push(), // value: value, fund: fund, @@ -107,7 +107,7 @@ struct OIMcoinStackV: View { .matchedGeometryEffect(id: targetID, in: wrapper.namespace, isSource: false) .onAppear { print("start flying \(targetID)") - withAnimation(.fly1) { + withAnimation(.move1) { flying = value // start flying } } @@ -147,7 +147,7 @@ struct OIMnotesView1: View { flying: $flying, canEdit: canEdit ) { - withAnimation(.fly1) { + withAnimation(.easeOut1) { amountVal -= value // remove on button press } } } } // ForEach @@ -181,7 +181,7 @@ struct OIMcoinsView1: View { flying: $flying, canEdit: canEdit ) { - withAnimation(Animation.easeIn1) { + withAnimation(.easeIn1) { amountVal -= value } } } } // ForEach diff --git a/TalerWallet1/Views/OIM/OIMcash.swift b/TalerWallet1/Views/OIM/OIMcash.swift @@ -7,34 +7,54 @@ */ import SwiftUI import taler_swift +import os.log +import SymLog let MAXSTACK = 4 enum FundState: Int { case idle + case flipping // user tapped to remove + case shouldFly // + button tapped case isFlying // after onAppear - case flipping // user tapped to remove - case morphingOut // too much in stack - case morphingIn // replacement - case isMorphing // + + case position // position to move to + case moving // move to position + case hiding // hide at position + case morphing // start -180 + case morphed // flip 180 + change value } /// data structure for a cash item on the table public struct OIMfund: Identifiable, Equatable, Hashable, Sendable { public let id: Int // support multiple funds with the same value - let value: UInt64 var state: FundState + var value: UInt64 // can be morphed + var morphTicker: Int? // `semaphore´ to reserve a fund - don't morph this twice + var flippedVal: UInt64? // the value to morph into var targetID: String { String(state == .shouldFly ? -Int(value) // match sourceID : id) } + var shouldFly: Bool { + state == .shouldFly + } + var isFlying: Bool { + state == .isFlying + } + var isFlipping: Bool { + state == .flipping + } + } public typealias OIMfunds = [OIMfund] // MARK: - -class OIMcash: ObservableObject { +final class OIMcash: ObservableObject, Sendable { + private let symLog = SymLogV(0) + let logger = Logger(subsystem: "net.taler.gnu", category: "OIMcash") var ticker = 0 // increment each time a fund is added (or melted from others) var currency: OIMcurrency @Published var funds: OIMfunds = [] @@ -52,26 +72,35 @@ class OIMcash: ObservableObject { } } + var delay: Double { +#if DEBUG + if debugAnimations { return 5.0 } +// return fastAnimations ? 5 : 1.0 +#endif + return fastAnimations ? 0.4 : 0.8 + } + + func sortByValue() -> OIMfunds { - // sorts ASCENDING - we check small values first - funds.sorted(by: { ($0.value < $1.value) || - ( ($0.value == $1.value) && ($0.id < $1.id) ) }) // but keep the order of the IDs + /// sorts ASCENDING but keeps the order of the IDs for identical values + funds.sorted(by: { + ($0.value < $1.value) || + (($0.value == $1.value) && ($0.id > $1.id)) + }) } func checkStacks(first firstCheck: UInt64, _ max: Int = MAXSTACK) -> UInt64? { + let sorted = sortByValue() /// same algorithm as OIMlayout.computeSpaces /// result is 0 if all stacks have 4 or less items - /// returns highest value with more than 4 items - - let sorted = sortByValue() - let count = sorted.count + /// returns lowest fund.value with more than 4 items var stackIndex = 0 var lastValue: UInt64 = 0 - for (index, fund) in sorted.enumerated() { + for fund in sorted { let value = fund.value let state = fund.state - if state == .shouldFly || state == .isFlying { // flying can not go on the stack + if fund.shouldFly || fund.isFlying { // flying can not go on the stack lastValue = 0 // let the next subview... stackIndex = 0 // ...start a new stack } else if lastValue != value { // different value? @@ -85,54 +114,113 @@ class OIMcash: ObservableObject { } return nil } - - func convert(_ nr: UInt64, of stackValue: UInt64, to biggerDenom: UInt64) -> Bool { +//MARK: - + func morph(_ nr: UInt64, of outValue: UInt64, to inValue: UInt64) -> Bool { + let morphTicker = ticker // capture ticker at start of morphing + symLog.log(" Start morphing #\(morphTicker)") let sorted = sortByValue() - var counter = nr var toDelete: OIMfunds = [] - var amount: UInt64 = 0 - for var fund in sorted { - if fund.value == stackValue && counter > 0 { - withAnimation(.basic1) { - fund.state = .morphingOut - updateFund(fund) - } + + var amount: UInt64 = 0 // ensure sum(morphedOut) is sum(morphedIn) + var counter = nr // counts outValues to be morphed + var added = 0 // counts inValues to be morphed + var first = true + for var fund in sorted { // remove newest funds first + if fund.value == outValue && fund.morphTicker == nil && counter > 0 { + fund.morphTicker = first ? morphTicker : -morphTicker + first = false + updateFund(fund) toDelete.append(fund) counter -= 1 - amount += stackValue + amount += outValue } } - DispatchQueue.main.async { - var added: OIMfunds = [] - withAnimation(.basic1) { - while amount >= biggerDenom { - let fund = self.addCash(value: biggerDenom, .morphingIn) - print(">>morphing to", fund.value, fund.id) - added.append(fund) - amount -= biggerDenom - } - } - withAnimation(.remove1) { - for fund in toDelete { - self.removeCash(id: fund.id, value: fund.value) - } + if counter > 0 { + logger.warning(" ❗️Yikes: didn't find \(nr) funds to morph, missing \(counter)") + } + + while amount >= inValue { + amount -= inValue + added += 1 // TODO: If we added more than 1, then we must morph more + } + if amount > 0 { + logger.warning(" ❗️Yikes: morph leftover:\(amount)") + } + + /// Stage 1: the fund with the highest id just got added - that is the anchor position for the morph + var morphFund = toDelete.removeFirst() + added -= 1 + symLog.log(">>taking this anchor \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") + withAnimation(.basic1) { + morphFund.flippedVal = inValue // layout.sortByValue will take this instead of value + morphFund.state = .position // to compute the position where the morph happens + updateFund(morphFund) + } + + withAnimation(.easeInOutDelay2) { + symLog.log(">>moving outgoing to \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") + for var delFund in toDelete { // move the rest of the leaving funds + delFund.state = .moving // to the morphing position + updateFund(delFund) } - withAnimation(.fly1) { - for var fund in added { - print("finish morphing", fund.id) - fund.state = .idle - self.updateFund(fund) - } + } + + /// Stage 2: After arriving at the position, without animation hide all outValues (except `morphing´) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + for var delFund in toDelete { + self.symLog.log(">>hide \(delFund.id)") + delFund.state = .hiding // make invisible + self.updateFund(delFund) // TODO: check if stack remains!!! } - if amount > 0 { - print(" ❗️Yikes: convert failed, leftover:", amount) + /// Stage 3: prepare flipping + morphFund.state = .morphing + self.symLog.log(">>prepare \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") + self.updateFund(morphFund) + /// flip animated + morphFund.state = .morphed + self.symLog.log(">>flip \(morphFund.id), \(morphFund.value) \(morphFund.state.rawValue)") +// withAnimation(.easeIn1) { - not needed, the button animates itself when flipping + self.updateFund(morphFund) +// } + self.symLog.log(">>flipped \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") + + /// Stage 4: move inValue to final position + DispatchQueue.main.asyncAfter(deadline: .now() + self.delay) { + /// transmute the fund + morphFund.value = inValue + morphFund.flippedVal = nil + morphFund.morphTicker = nil // release `semaphore´ + self.symLog.log(">>transmute \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") + self.updateFund(morphFund) + + + + morphFund.state = .idle + self.symLog.log(">>move to final position \(morphFund.id) \(morphFund.state.rawValue)") + withAnimation(.move1) { + self.updateFund(morphFund) + while added > 0 { + var fund = toDelete.removeFirst() + fund.value = inValue // transmute to inValue + fund.morphTicker = nil // release `semaphore´ + fund.state = .idle + self.updateFund(fund) + added -= 1 + } + // remaining outValues were already invisible, but we need to animate the stack they came from + for var delFund in toDelete { + self.symLog.log(">>remove \(delFund.id)") + self.removeCash(id: delFund.id, value: delFund.value) + } + } } - } + } // asyncAfter delay return counter == 0 } func compactStacks(_ value: UInt64) -> Bool { + symLog.log(" Start compactStacks \(value)") let denominations = currency.bankNotes + currency.bankCoins if let index = denominations.firstIndex(where: { $0 == value }) { if index > 0 { // does a bigger denomination exist? @@ -140,7 +228,7 @@ class OIMcash: ObservableObject { let nextIndex = index-1 let nextValue = denominations[nextIndex] // let next = next_bigger_denomination if nextValue == value5x { - return convert(5, of: value, to: nextValue) + return morph(5, of: value, to: nextValue) } // since we want to "convert" adjacent denominations (some smaller will become 1 larger fund), // we cannot use the whole 5 smaller funds (4 in stack, 1 just added) if next is not 5 times bigger @@ -152,7 +240,7 @@ class OIMcash: ObservableObject { let secondValue = denominations[secondIdx] if secondValue <= value5x && secondValue.isMultiple(of: value) { let nrToDelete = secondValue / value - return convert(nrToDelete, of: value, to: secondValue) + return morph(nrToDelete, of: value, to: secondValue) } } } @@ -160,7 +248,7 @@ class OIMcash: ObservableObject { // or there are already some funds of next on the table if nextValue.isMultiple(of: value) { let nrToDelete = nextValue / value - return convert(nrToDelete, of: value, to: nextValue) + return morph(nrToDelete, of: value, to: nextValue) } // Now this is tricky - there are already some funds of next on the table, but next is not a multiple of value // But maybe 2 of next are a multiple - e.g. value=10, next=25 @@ -168,7 +256,7 @@ class OIMcash: ObservableObject { if nextValue2x.isMultiple(of: value) { let nrToDelete = nextValue * 2 / value if nrToDelete <= 5 { - return convert(nrToDelete, of: value, to: nextValue) // 5*10 = 2*25 + return morph(nrToDelete, of: value, to: nextValue) // 5*10 = 2*25 } } } @@ -188,10 +276,10 @@ class OIMcash: ObservableObject { return funds.filter { $0.value <= firstCoinVal } } - func addCash(value: UInt64, _ newState: FundState = .shouldFly) -> OIMfund { - let fund = OIMfund(id: ticker, value: value, state: newState) + func addCash(value: UInt64, _ newState: FundState = .shouldFly, _ flippedVal: UInt64? = nil) -> OIMfund { + let fund = OIMfund(id: ticker, state: newState, value: value, flippedVal: flippedVal) ticker += 1 - print(">>adding", value, fund.id) + symLog.log(">>adding \(value) \(fund.id)") funds.append(fund) return fund } @@ -231,7 +319,7 @@ class OIMcash: ObservableObject { var count = shownNotes.count while count > wanted { let note = shownNotes[0] - print("update: remove \(note.value)") + symLog.log("update: remove \(note.value)") if let index = array.firstIndex(of: note) { array.remove(at: index) changed = true @@ -240,9 +328,9 @@ class OIMcash: ObservableObject { count -= 1 } while count < wanted { - let note = OIMfund(id: ticker, value: value, state: .idle) + let note = OIMfund(id: ticker, state: .idle, value: value, flippedVal: nil) ticker += 1 - print("update: add \(note.value)") + symLog.log("update: add \(note.value)") array.append(note) changed = true count += 1 diff --git a/TalerWallet1/Views/OIM/OIMcurrencyButton.swift b/TalerWallet1/Views/OIM/OIMcurrencyButton.swift @@ -46,8 +46,10 @@ fileprivate struct OIMmod: AnimatableModifier { let value: UInt64 let name: String? + let flippedName: String? let availableVal: UInt64 let canEdit: Bool + let invisible: Bool let isFlipped: Bool var pct: CGFloat let action: () -> Void @@ -60,27 +62,55 @@ struct OIMmod: AnimatableModifier { func body(content: Content) -> some View { let shadow = (3 - 8) * pct + 8 let currencyImage = OIMcurrencyImage(name) - let image = currencyImage.image - .resizable() - .scaledToFit() let oWidth = currencyImage.oWidth / 4 let oHeight = currencyImage.oHeight / 4 + let image = currencyImage.image + .resizable() + .scaledToFit() + + let valueImage = image + .rotation3DEffect(.degrees(isFlipped ? 90 : 0), + axis: (x: 0.0, y: 1.0, z: 0.0)) + let backImage = Group { + if let flippedName { + let flippedImage = OIMcurrencyImage(flippedName) +// let _ = print(" OIMmod", isFlipped, value, flippedName) + flippedImage.image + .resizable() + .scaledToFit() + } else { +// if isFlipped { +// let _ = print(" OIMmod no image", isFlipped, value, flippedName) +// } + EmptyView() + } + } + .rotation3DEffect(.degrees(isFlipped ? 0 : -90), + axis: (x: 0.0, y: 1.0, z: 0.0)) + let animatedImage = ZStack { + valueImage + .animation(isFlipped ? .linear : .linear.delay(0.35), value: isFlipped) + backImage + .animation(isFlipped ? .linear.delay(0.35) : .linear, value: isFlipped) + } + return Group { - if canEdit && availableVal >= value { - Button(action: action) { - image + if invisible { + image.opacity(0) + } else if !canEdit || availableVal < value { + animatedImage + .opacity(canEdit ? 0.5 : 1.0) + } else { + Button(action: action) { // TODO: disable after tapped + animatedImage } .buttonStyle(OIMbuttonStyle()) - } else { - image - .opacity(canEdit ? 0.5 : 1.0) } - } - .frame(width: (isFlipped ? 0.7 * oWidth : oWidth), - height: (isFlipped ? 0.7 * oHeight : oHeight)) + } // Group +// .frame(width: (isFlipped != 0 ? 0.7 * oWidth : oWidth), +// height: (isFlipped != 0 ? 0.7 * oHeight : oHeight)) + .frame(width: oWidth, height: oHeight) .shadow(radius: shadow) - .rotation3DEffect(.degrees(isFlipped ? 135 : 0), axis: (x: 0, y: 1, z: 0)) - .animation(.basic1, value: isFlipped) .accessibilityLabel(Text("\(value)", comment: "VoiceOver")) // TODO: currency name } } @@ -97,18 +127,36 @@ struct OIMcurrencyButton: View { @EnvironmentObject private var cash: OIMcash + func imgName(_ value: UInt64?) -> String? { + if let value { + return value > currency.bankCoins[0] ? currency.noteName(value) + : currency.coinName(value) + } + return nil + } + var body: some View { let currency = cash.currency let value = fund.value - let name = value > currency.bankCoins[0] ? currency.noteName(value) - : currency.coinName(value) + let isMorphing = fund.morphTicker != nil + let flippedVal = fund.flippedVal // Use EmptyView, because the modifier actually ignores // the value passed to its body() function. + let isFlipped: Bool = switch fund.state { + case .morphed: true + case .flipping: true + default: false + } +// if fund.id == 6 { +// let _ = print("Fund", fund.id, value, fund.state, flippedVal) +// } EmptyView().modifier(OIMmod(value: value, - name: name, + name: imgName(value), + flippedName: imgName(flippedVal), availableVal: availableVal, - canEdit: canEdit, - isFlipped: fund.state == .flipping, + canEdit: canEdit && !isMorphing, // cannot edit morphing funds + invisible: fund.state == .hiding, + isFlipped: isFlipped && flippedVal != nil, pct: pct, action: action)) } diff --git a/TalerWallet1/Views/OIM/OIMcurrencyScroller.swift b/TalerWallet1/Views/OIM/OIMcurrencyScroller.swift @@ -22,7 +22,7 @@ struct OIMcurrencyScroller: View { ScrollView(.horizontal) { HStack(alignment: .bottom, spacing: 10) { ForEach(currency.bankNotes, id: \.self) { value in - let fund = OIMfund(id: -Int(value), value: value, state: .idle) + let fund = OIMfund(id: -Int(value), state: .idle, value: value, flippedVal: nil) let sourceID = -Int(value) OIMcurrencyButton(stack: stack.push(), fund: fund, @@ -35,7 +35,7 @@ struct OIMcurrencyScroller: View { .matchedGeometryEffect(id: String(sourceID), in: wrapper.namespace, isSource: true) } ForEach(currency.bankCoins, id: \.self) { value in - let fund = OIMfund(id: -Int(value), value: value, state: .idle) + let fund = OIMfund(id: -Int(value), state: .idle, value: value, flippedVal: nil) let sourceID = -Int(value) OIMcurrencyButton(stack: stack.push(), fund: fund, diff --git a/TalerWallet1/Views/OIM/OIMlayout.swift b/TalerWallet1/Views/OIM/OIMlayout.swift @@ -7,13 +7,20 @@ */ import SwiftUI import SymLog +import os.log struct OIMid: LayoutValueKey { static let defaultValue = 0 } +struct OIMmorph: LayoutValueKey { + static let defaultValue = 0 +} struct OIMvalue: LayoutValueKey { static let defaultValue: UInt64 = 0 } +struct OIMflippedval: LayoutValueKey { + static let defaultValue: UInt64 = 0 +} struct OIMfundState: LayoutValueKey { static let defaultValue: FundState = .idle } @@ -23,9 +30,15 @@ extension View { func oimID(_ value: Int) -> some View { self.layoutValue(key: OIMid.self, value: value) } + func oimMorph(_ value: Int) -> some View { + self.layoutValue(key: OIMmorph.self, value: value) + } func oimValue(_ value: UInt64) -> some View { self.layoutValue(key: OIMvalue.self, value: value) } + func oimFlippedVal(_ value: UInt64) -> some View { + self.layoutValue(key: OIMflippedval.self, value: value) + } func oimFundState(_ value: FundState) -> some View { self.layoutValue(key: OIMfundState.self, value: value) } @@ -35,14 +48,16 @@ extension View { extension LayoutSubview { var oimID: Int { self[OIMid.self] } var oimValue: UInt64 { self[OIMvalue.self] } + var oimFlippedVal: UInt64 { self[OIMflippedval.self] } var oimFundState: FundState { self[OIMfundState.self] } } // MARK: - -// renders a stack of (identical) banknotes with offset 10,20 +// renders a stack of funds (banknotes, coins) @available(iOS 16.4, *) struct OIMlayoutView: View { private let symLog = SymLogV(0) +// let logger = Logger(subsystem: "net.taler.gnu", category: "OIMlayoutView") let stack: CallStack let funds: OIMfunds @Binding var amountVal: UInt64 @@ -52,98 +67,90 @@ struct OIMlayoutView: View { @EnvironmentObject private var wrapper: NamespaceWrapper @State private var checkStacks: UInt64 = 0 + func startFlying(fundID: Int) { + if let index = funds.firstIndex(where: { $0.id == fundID }) { + var fund = funds[index] + symLog.log("*** start flying:\(fund.value) \(fundID)") + withAnimation(.move1) { + fund.state = .isFlying // switch off matching + cash.updateFund(fund) + } + DispatchQueue.main.asyncAfter(deadline: .now() + cash.delay) { + symLog.log("*** end flying:\(fund.value) \(fundID)") + withAnimation(.move1) { + fund.state = .idle // move onto stack + cash.updateFund(fund) + } + if checkStacks > fund.value || checkStacks == 0 { + symLog.log("*** checkStacks:\(fund.value)") + checkStacks = fund.value + } + } + } + } + + func flipRemove(fundID: Int) { + if let index = funds.firstIndex(where: { $0.id == fundID }) { + var fund = funds[index] + symLog.log("*** start flipping:\(fund.value) \(fundID)") + withAnimation(.basic1) { + fund.state = .flipping + cash.updateFund(fund) + } + DispatchQueue.main.asyncAfter(deadline: .now() + cash.delay) { + symLog.log("*** remove:\(fund.value) \(fundID)") + withAnimation(.move1) { + cash.removeCash(id: fundID, value: fund.value) + } + } + } + } + var body: some View { OIMlayout { - ForEach(funds){ item in - var fund = item - + ForEach(funds) { fund in let value = fund.value + let fundID = fund.id let fundState = fund.state let shouldFly = fundState == .shouldFly - let isFlipped = fundState == .flipping - let shouldMorph = fundState == .morphingIn OIMcurrencyButton(stack: stack.push(), fund: fund, currency: cash.currency, availableVal: value, - canEdit: canEdit && !isFlipped, + canEdit: canEdit, pct: shouldFly ? 0.0 : 1.0 ) { // remove on button press if amountVal >= fund.value { amountVal -= fund.value } else { - symLog.log(" ❗️Yikes - trying to make amount negative") + symLog.log(" ❗️Yikes - trying to subtract \(fund.value) from amount \(amountVal)") amountVal = 0 } - withAnimation(.basic1) { - fund.state = .flipping - cash.updateFund(fund) - } - DispatchQueue.main.async { - withAnimation(.remove1) { - symLog.log(" OIMlayoutView remove \(value)") - cash.removeCash(id: fund.id, value: fund.value) - } - } + flipRemove(fundID: fundID) } - .zIndex(Double(fund.id)) - .id(fund.id) + .zIndex(Double(fundID)) + .id(fundID) .oimValue(value) - .oimID(fund.id) + .oimFlippedVal(fund.flippedVal ?? 0) + .oimID(fundID) .oimFundState(fundState) - .matchedGeometryEffect(id: fund.targetID, in: wrapper.namespace, isSource: false) // !isFlying + .matchedGeometryEffect(id: fund.targetID, in: wrapper.namespace, isSource: false) .onAppear { if shouldFly { -// print(" ->OIMlayoutView.onAppear stop matching \(value), \(fund.id)") - withAnimation(.fly1) { - print("*** start flying:", value, fund.id) - fund.state = .isFlying // switch off matching - cash.updateFund(fund) - } - DispatchQueue.main.asyncAfter(deadline: .now() + (fastAnimations ? 0.6 : 1.0)) { - withAnimation(.fly1) { - print("*** end flying:", value, fund.id) - fund.state = .idle // move onto stack - cash.updateFund(fund) - } - DispatchQueue.main.asyncAfter(deadline: .now() + (fastAnimations ? 0.6 : 1.0)) { - checkStacks = value - } - } - } else if isFlipped { - withAnimation(.fly1) { - print("*** start flipping:", value, fund.id) - fund.state = .idle - cash.updateFund(fund) - } - } else if shouldMorph { - withAnimation(.fly1) { - print("*** start morphing:", value, fund.id) - fund.state = .isMorphing - cash.updateFund(fund) - } - DispatchQueue.main.asyncAfter(deadline: .now() + (fastAnimations ? 0.6 : 1.0)) { - withAnimation(.fly1) { - print("*** end morphing:", value, fund.id) - fund.state = .idle // move onto stack - cash.updateFund(fund) - } - } -// } else { -// print(" ->OIMlayoutView.onAppear ignore \(value), \(fund.id)") + startFlying(fundID: fundID) + } else { +// print(" ->OIMlayout ForEach fund.onAppear ignore \(value), \(fundID)") } } } } .onChange(of: checkStacks) { value in if value > 0 { - print("*** check:", value) + symLog.log("*** onChange(of: checkStacks) \(value)") var firstCheck = value if let moreThan4 = cash.checkStacks(first: firstCheck) { firstCheck = 0 -// withAnimation(.fly1) { - cash.compactStacks(moreThan4) -// } + cash.compactStacks(moreThan4) } checkStacks = 0 } @@ -153,61 +160,81 @@ struct OIMlayoutView: View { // MARK: - @available(iOS 16.4, *) struct OIMlayout: Layout { -// struct CacheData { -//// var maxHeight: CGFloat -// var spaces: [CGSize] -// } -// -// func computeMaxHeight(sorted: [LayoutSubview]) -> CGFloat { -// -// return sorted.map { $0.sizeThatFits(.unspecified) }.reduce(0) { max($0, $1.height) } -// } -// -// func computeSpaces(_ sorted: [LayoutSubview]) -> [CGSize] { -// // -// return sorted.indices.map { idx in -// guard idx < sorted.count - 1 else { return CGSize.zero } -// -// return -// } -// } -// -// func makeCache(subviews: Subviews) -> CacheData { -// let sorted = sortByValue(subviews) -// return CacheData(maxHeight: computeMaxHeight(sorted), -// spaces: computeSpaces(subviews: sorted)) -// } -// -// func updateCache(_ cache: inout CacheData, subviews: Subviews) { -// cache.maxHeight = computeMaxHeight(subviews: subviews) -// cache.spaces = computeSpaces(subviews: subviews) -// } func sortByValue(_ subviews: LayoutSubviews) -> [LayoutSubview] { // sorts DESCENDING - we render the denominations top-down - subviews.sorted(by: { ($0.oimValue > $1.oimValue) || - ( ($0.oimValue == $1.oimValue) && ($0.oimID < $1.oimID) ) }) + subviews.sorted(by: { + let state0 = $0.oimFundState + let state1 = $1.oimFundState + let value0 = $0.oimValue + let value1 = $1.oimValue + let flipV0 = $0.oimFlippedVal + let flipV1 = $1.oimFlippedVal + let id0 = $0.oimID + let id1 = $1.oimID + let isSameVal = value0 == value1 + let id0isSmaller = id0 < id1 + let id0isBigger = id1 < id0 + + let isMorphing1 = state1 == .position + || state1 == .morphing + || state1 == .morphed + if isMorphing1 { + if flipV1 > 0 { + let firstIsBigger = value0 > flipV1 + let sameFlip = value0 == flipV1 + + let sameFlip_ID0smaller = sameFlip && id0isSmaller + let result = firstIsBigger || sameFlip_ID0smaller + return result + } + } + + let isMorphing0 = state0 == .position + || state0 == .morphing + || state0 == .morphed + if isMorphing0 { + if flipV0 > 0 { + let firstIsBigger = flipV0 > value1 + let sameFlip = value1 == flipV0 + + let sameFlip_ID0smaller = sameFlip && id0isSmaller + let result = firstIsBigger || sameFlip_ID0smaller + return result + } + } + let firstIsBigger = value0 > value1 + // but keep the order of the IDs for identical values + let sameVal_ID0smaller = isSameVal && id0isSmaller + let result = firstIsBigger || sameVal_ID0smaller + return result + }) } func computeSpaces(_ sorted: [LayoutSubview], _ max: Int = MAXSTACK) -> [Int] { + /// same algorithm as OIMcash.checkStacks /// returns array of stackIndexes /// 0 ==> next view starts a new stack /// 1..3 ==> next view is on the stack + /// -1 ==> view is invisible, will be deleted var spaces: [Int] = [] - let count = sorted.count - var stackIndex = 0 var lastValue: UInt64 = 0 for (index, subview) in sorted.enumerated() { let value = subview.oimValue let state = subview.oimFundState + let id = subview.oimID + var ignore = 0 if state == .shouldFly || state == .isFlying // flying can not go on the stack - || state == .morphingIn // nor can morphing in... - || state == .morphingOut { // ...or morphing out + || state == .position // nor can the position where we morph... + || state == .morphing // ...or the morphing fund... + || state == .morphed { // ...or morphed fund // we always morph out from the end, thus there will be no more idling funds with the same value lastValue = 0 // let the next subview... stackIndex = 0 // ...start a new stack + } else if state == .moving || state == .hiding { + ignore = -1 } else if lastValue != value { // different value? lastValue = value // save this value for the next subview stackIndex = 0 // start a new stack @@ -217,7 +244,7 @@ struct OIMlayout: Layout { stackIndex = 0 // stack is full, start a new one but keep the value } } if index > 0 { - spaces.append(stackIndex) + spaces.append(ignore == 0 ? stackIndex : ignore) } } return spaces @@ -226,11 +253,11 @@ struct OIMlayout: Layout { func accumulate(spaces: [Int], _ viewSizes: [CGSize], spacing: CGFloat, xOffset: CGFloat) -> CGFloat { var accumulatedSpaces: CGFloat = .zero for (idx, stackIndex) in spaces.enumerated() { - if stackIndex > 0 { + if stackIndex > 0 { // next view is on the stack, add xOffset, subtract width accumulatedSpaces += (xOffset - viewSizes[idx].width) - } else { + } else if stackIndex == 0 { // start a new stack, add spacing accumulatedSpaces += spacing - } + } // else ignore view } return accumulatedSpaces } @@ -251,7 +278,7 @@ struct OIMlayout: Layout { var result = CGFloat.zero for (index, subview) in views.enumerated() { let state = subview.oimFundState - if state != .morphingOut { + if state != .moving && state != .hiding { let size = sizes[index] result += size.width } @@ -265,7 +292,6 @@ struct OIMlayout: Layout { let sorted = sortByValue(subviews) let viewSizes = sorted.map { $0.sizeThatFits(proposal) } // <- THIS takes time... let maxHeight = viewSizes.reduce(0) { max($0, $1.height) } // get the max height -// let viewWidths = viewSizes.reduce(0) { $0 + $1.width } // add up all widths let viewWidths = accumulate(views: sorted, sizes: viewSizes) // add up all widths let spaces = computeSpaces(sorted) @@ -296,12 +322,12 @@ struct OIMlayout: Layout { // // TODO: compute the inner-stack offsets // spaceToFill -= viewWidths // might be negative } else { // unspecified - print("Yikes❗️ width only:", spaceToFill) +// print("Yikes❗️ width only:", spaceToFill) } } else if var heightToFill = proposal.height { - print("Yikes❗️ height only:", heightToFill) +// print("Yikes❗️ height only:", heightToFill) } else { // unspecified - print("Yikes❗️ should NEVER happen:", proposal) +// print("Yikes❗️ should NEVER happen:", proposal) } // while inner-stack offsets is not yet implemented, return ideal size @@ -325,42 +351,43 @@ struct OIMlayout: Layout { var morphPt = CGPoint.zero for idx in sorted.indices { let subview = sorted[idx] + let id = subview.oimID let state = subview.oimFundState - if state == .morphingIn || state == .isMorphing { - print("morphing into ク\(subview.oimValue),\(subview.oimID) at", pt) + if state == .position || state == .morphing || state == .morphed { +// if morphPt != pt { print("set morphPt \(morphPt) to ク\(subview.oimValue),\(subview.oimID) at", pt) } morphPt = pt } subview.place(at: pt, anchor: .topLeading, proposal: proposal) - if state == .morphingOut { + if state == .moving || state == .hiding { if morphPt != CGPoint.zero { - print("morphing ク\(subview.oimValue),\(subview.oimID) from \(pt) to", morphPt) +// print("morphing ク\(subview.oimValue) \(subview.oimID) \(state) from \(pt) to", morphPt) subview.place(at: morphPt, anchor: .topLeading, proposal: proposal) } else { - print("Yikes: no morphing point for ク", subview.oimValue, subview.oimID) +// symLog.log("Yikes: no morphing point for ク", subview.oimValue, subview.oimID, state) } - } else if state != .morphingIn && state != .isMorphing { - print("placing ク\(subview.oimValue),\(subview.oimID) at", pt) +// } else if state != .morphingIn && state != .morphedIn { +// print("placing ク\(subview.oimValue),\(subview.oimID) at", pt) } if idx < sorted.count - 1 { let space = spaces[idx] let nextView = sorted[idx+1] let state = nextView.oimFundState - if state != .morphingOut { + if state != .moving && state != .hiding { if space == 0 || isMax { - // next subview starts a new stack +// print("Start new stack for ク", nextView.oimValue, nextView.oimID, state) let width = subview.sizeThatFits(proposal).width pt.x += width + spacing pt.y = bounds.minY - } else { - // netx subview is on this stack + } else if space > 0 { +// print(" place on stack ク", nextView.oimValue, nextView.oimID, state) pt.x += offset.x pt.y += offset.y +// } else { +// print("ignore ク", nextView.oimValue, nextView.oimID, state) } } // place subviews morphing out at position of view morphing in } } } } - - diff --git a/TalerWallet1/Views/OIM/OIMlineView.swift b/TalerWallet1/Views/OIM/OIMlineView.swift @@ -7,11 +7,13 @@ */ import SwiftUI import taler_swift +import SymLog fileprivate let horzSpacing: CGFloat = 20 fileprivate let vertSpacing: CGFloat = 10 struct OIMlineView: View { + private let symLog = SymLogV(0) let stack: CallStack @Binding var amountVal: UInt64 @Binding var tappedVal: UInt64 @@ -40,11 +42,11 @@ struct OIMlineView: View { let notes = OIMlayoutView(stack: stack.push(), funds: cash.notes(), amountVal: $amountVal, - canEdit: true) + canEdit: canEdit) let coins = OIMlayoutView(stack: stack.push(), funds: cash.coins(), amountVal: $amountVal, - canEdit: true) + canEdit: canEdit) LayoutThatFits([HStackLayout(alignment: .center), VStackLayout()]) { notes #if DEBUG @@ -115,26 +117,25 @@ struct OIMlineView: View { } .onChange(of: tappedVal) { newVal in if newVal > 0 { - print(">>tapped ", newVal) + symLog.log(">>tapped \(newVal)") tappedVal = 0 - let ms = fastAnimations ? 250 : 500 DispatchQueue.main.async { // next layout cycle - withAnimation(.fly1) { - if #available(iOS 16.4, *) { - print("\n>>addCash", newVal) + withAnimation(.move1) { + if #available(iOS 16.0, *) { + symLog.log(">>addCash \(newVal)") cash.addCash(value: newVal) amountVal += newVal // update directly } else { - print("\n>>start flying", newVal, flying) + symLog.log("\n>>start flying \(newVal) \(flying)") myTappedVal = newVal } } - if #unavailable(iOS 16.4) { - print("\n>>reset flying", newVal, flying) + if #unavailable(iOS 16.0) { + symLog.log("\n>>reset flying \(newVal) \(flying)") flying = 0 // remove immediately after it flew in, but outside of the animation block - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(ms)) { - print("\n>>dissolve", newVal, flying) + DispatchQueue.main.asyncAfter(deadline: .now() + cash.delay) { + symLog.log("\n>>dissolve \(newVal) \(flying)") withAnimation(.basic1) { amountVal += newVal // update after value flew in myTappedVal = 0