Skip to content

Commit dd778ab

Browse files
authored
[Woo POS][Barcodes] Handle transitions for set up flow states (#15942)
2 parents 292b486 + d047c20 commit dd778ab

File tree

5 files changed

+156
-21
lines changed

5 files changed

+156
-21
lines changed

WooCommerce/Classes/POS/Presentation/Barcode Scanner Setup/PointOfSaleBarcodeScannerSetup.swift

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,39 @@ import SwiftUI
44
struct PointOfSaleBarcodeScannerSetup: View {
55
@Binding var isPresented: Bool
66
@State private var flowManager: PointOfSaleBarcodeScannerSetupFlowManager
7+
@Environment(\.posModalParentSize) var parentSize
78

89
init(isPresented: Binding<Bool>) {
910
self._isPresented = isPresented
1011
self.flowManager = PointOfSaleBarcodeScannerSetupFlowManager(isPresented: isPresented)
1112
}
1213

1314
var body: some View {
14-
VStack(spacing: POSSpacing.xxLarge) {
15-
VStack {
16-
currentContent
17-
.frame(maxWidth: .infinity, maxHeight: .infinity)
18-
Spacer()
19-
}
20-
.scrollVerticallyIfNeeded()
15+
AnimatedTransitionContainer(
16+
maxWidth: parentSize.width * Constants.parentWidthRatio,
17+
maxHeight: parentSize.height * Constants.maxParentHeightRatio,
18+
id: flowManager.currentStepKey
19+
) {
20+
VStack(spacing: POSSpacing.xxLarge) {
21+
ScrollView(showsIndicators: false) {
22+
HStack {
23+
Spacer()
24+
currentContent
25+
Spacer()
26+
}
27+
}
28+
.scrollBounceBehavior(.basedOnSize, axes: [.vertical])
2129

22-
// Bottom buttons
23-
if flowManager.buttonConfiguration.primaryButton != nil || flowManager.buttonConfiguration.secondaryButton != nil {
24-
PointOfSaleFlowButtonsView(configuration: flowManager.buttonConfiguration)
30+
// Bottom buttons
31+
if flowManager.buttonConfiguration.primaryButton != nil || flowManager.buttonConfiguration.secondaryButton != nil {
32+
PointOfSaleFlowButtonsView(configuration: flowManager.buttonConfiguration)
33+
}
2534
}
26-
}
27-
.posModalCloseButton(action: {
28-
isPresented = false
29-
})
30-
.padding(POSPadding.xxLarge)
31-
.background(Color.posSurfaceBright)
32-
.containerRelativeFrame([.horizontal, .vertical]) { length, _ in
33-
max(length * 0.75, Constants.modalFrameMaxSmallDimension)
35+
.posModalCloseButton(action: {
36+
isPresented = false
37+
})
38+
.padding(POSPadding.xxLarge)
39+
.background(Color.posSurfaceBright)
3440
}
3541
.onAppear {
3642
ServiceLocator.analytics.track(.pointOfSaleBarcodeScannerSetupFlowShown)
@@ -80,7 +86,8 @@ struct PointOfSaleBarcodeScannerSetup: View {
8086

8187
// MARK: - Constants
8288
private enum Constants {
83-
static var modalFrameMaxSmallDimension: CGFloat { 616 }
89+
static var maxParentHeightRatio: CGFloat { 0.9 }
90+
static var parentWidthRatio: CGFloat { 0.75 }
8491
}
8592

8693
// MARK: - Private Localization Extension
@@ -114,3 +121,99 @@ private extension PointOfSaleBarcodeScannerSetup {
114121
#Preview {
115122
PointOfSaleBarcodeScannerSetup(isPresented: .constant(true))
116123
}
124+
125+
/// A container view that animates changes in its child content with a fade-out and fade-in transition,
126+
/// while also smoothly animating changes in height.
127+
///
128+
/// - On first appear: content shows instantly (no fade).
129+
/// - On content change: fades out old content, replaces it, then fades in new content.
130+
/// - Handles height changes with a spring animation.
131+
///
132+
@available(iOS 17.0, *)
133+
private struct AnimatedTransitionContainer<Content: View, ID: Equatable>: View {
134+
let maxWidth: CGFloat
135+
let maxHeight: CGFloat
136+
let contentID: ID
137+
let contentBuilder: () -> Content
138+
139+
@State private var visibleContent: Content
140+
@State private var previousID: ID
141+
@State private var animatedHeight: CGFloat = 0
142+
@State private var isVisible: Bool = true
143+
@State private var hasAppeared: Bool = false
144+
145+
private let animationDuration: CGFloat = 0.3
146+
147+
init(
148+
maxWidth: CGFloat,
149+
maxHeight: CGFloat,
150+
id: ID,
151+
@ViewBuilder content: @escaping () -> Content
152+
) {
153+
self.maxWidth = maxWidth
154+
self.maxHeight = maxHeight
155+
self.contentID = id
156+
self.contentBuilder = content
157+
self._visibleContent = State(initialValue: content())
158+
self._previousID = State(initialValue: id)
159+
}
160+
161+
var body: some View {
162+
visibleContent
163+
.opacity(isVisible ? 1 : 0)
164+
// First layout pass: constrain content to let scrollView in content to configure itself
165+
.frame(width: maxWidth)
166+
.frame(maxHeight: maxHeight)
167+
.fixedSize(horizontal: false, vertical: true)
168+
// Measure the actual height after ScrollView has decided if it needs to scroll
169+
.background(
170+
GeometryReader { proxy in
171+
Color.clear
172+
.onAppear {
173+
updateSize(to: proxy.size.height)
174+
}
175+
.onChange(of: proxy.size) { newSize in
176+
updateSize(to: newSize.height)
177+
}
178+
}
179+
)
180+
// Second layout pass: create animated viewport using measured height
181+
.frame(width: maxWidth)
182+
.frame(height: animatedHeight)
183+
.clipped()
184+
.onAppear {
185+
hasAppeared = true
186+
}
187+
.onChange(of: contentID) { newID in
188+
guard newID != previousID else { return }
189+
190+
if hasAppeared {
191+
withAnimation(.easeInOut(duration: animationDuration / 2)) {
192+
isVisible = false
193+
}
194+
195+
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration / 2) {
196+
visibleContent = contentBuilder()
197+
previousID = newID
198+
199+
withAnimation(.easeInOut(duration: animationDuration)) {
200+
isVisible = true
201+
}
202+
}
203+
} else {
204+
// First load, no animation
205+
visibleContent = contentBuilder()
206+
previousID = newID
207+
isVisible = true
208+
}
209+
}
210+
}
211+
212+
private func updateSize(to newHeight: CGFloat) {
213+
guard newHeight > 0 else { return }
214+
215+
withAnimation(.spring(duration: hasAppeared ? animationDuration : 0)) {
216+
animatedHeight = min(newHeight, maxHeight)
217+
}
218+
}
219+
}

WooCommerce/Classes/POS/Presentation/Barcode Scanner Setup/PointOfSaleBarcodeScannerSetupFlow.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class PointOfSaleBarcodeScannerSetupFlow {
99
private let onBackToSelection: () -> Void
1010
fileprivate let onDismiss: () -> Void
1111
private var flowSteps: [PointOfSaleBarcodeScannerStepID: PointOfSaleBarcodeScannerSetupStep] = [:]
12-
private var currentStepKey: PointOfSaleBarcodeScannerStepID = .setupBarcodeHID
12+
private(set) var currentStepKey: PointOfSaleBarcodeScannerStepID = .setupBarcodeHID
1313
private let analytics: Analytics
1414

1515
init(scannerType: PointOfSaleBarcodeScannerType,

WooCommerce/Classes/POS/Presentation/Barcode Scanner Setup/PointOfSaleBarcodeScannerSetupFlowManager.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ class PointOfSaleBarcodeScannerSetupFlowManager {
1212
private let analytics: Analytics
1313
private var keyboardObserver: NSObjectProtocol?
1414

15+
var currentStepKey: String? {
16+
currentFlow?.currentStepKey.rawValue
17+
}
18+
1519
init(isPresented: Binding<Bool>, analytics: Analytics = ServiceLocator.analytics) {
1620
self._isPresented = isPresented
1721
self.analytics = analytics

WooCommerce/Classes/POS/Presentation/Barcode Scanner Setup/PointOfSaleFlowButtonsView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import SwiftUI
22

3+
@available(iOS 17.0, *)
34
struct PointOfSaleFlowButtonsView: View {
45
let configuration: PointOfSaleFlowButtonConfiguration
56

@@ -21,6 +22,7 @@ struct PointOfSaleFlowButtonsView: View {
2122
.disabled(!primaryButton.isEnabled)
2223
}
2324
}
25+
.geometryGroup()
2426
}
2527
}
2628

WooCommerce/Classes/POS/Presentation/Reusable Views/POSModalViewModifier.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import SwiftUI
22

33
struct POSRootModalViewModifier: ViewModifier {
44
@EnvironmentObject var modalManager: POSModalManager
5+
@State private var modalParentSize: CGSize = UIScreen.main.bounds.size
56

67
private let animationDuration = Constants.animationDuration
78
private let scaleTransitionAmount = Constants.scaleTransitionAmount
@@ -21,10 +22,11 @@ struct POSRootModalViewModifier: ViewModifier {
2122
modalManager.dismiss()
2223
}
2324
}
24-
// Don't scale/fade in the backdrop
25+
// Don't scale/fade in the backdrop
2526
.animation(nil, value: modalManager.isPresented)
2627
ZStack {
2728
modalManager.getContent()
29+
.environment(\.posModalParentSize, modalParentSize)
2830
.background(Color.posSurfaceBright)
2931
.cornerRadius(POSCornerRadiusStyle.extraLarge.value)
3032
.posShadow(.large, cornerRadius: POSCornerRadiusStyle.extraLarge.value)
@@ -38,8 +40,17 @@ struct POSRootModalViewModifier: ViewModifier {
3840
.transition(.scale(scale: scaleTransitionAmount).combined(with: .opacity))
3941
}
4042
}
43+
.measureFrame { frame in
44+
updateModalParentSize(with: frame.size)
45+
}
4146
.animation(.easeInOut(duration: animationDuration), value: modalManager.isPresented)
4247
}
48+
49+
private func updateModalParentSize(with size: CGSize) {
50+
if size != modalParentSize && size != .zero {
51+
modalParentSize = size
52+
}
53+
}
4354
}
4455

4556
private extension POSRootModalViewModifier {
@@ -178,3 +189,18 @@ extension View {
178189
self.modifier(POSInteractiveDismissModifier(disabled: disabled))
179190
}
180191
}
192+
193+
// MARK: - POS Modal Parent Size Environment
194+
195+
/// Environment key for tracking the current screen size in POS modals
196+
struct POSModalParentSizeKey: EnvironmentKey {
197+
static let defaultValue: CGSize = UIScreen.main.bounds.size
198+
}
199+
200+
extension EnvironmentValues {
201+
/// The current screen size available to the POS modal
202+
var posModalParentSize: CGSize {
203+
get { self[POSModalParentSizeKey.self] }
204+
set { self[POSModalParentSizeKey.self] = newValue }
205+
}
206+
}

0 commit comments

Comments
 (0)