@@ -4,33 +4,39 @@ import SwiftUI
44struct 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
8288private 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+ }
0 commit comments