@@ -34,8 +34,11 @@ const STEP_EASING = Easing.inOut(Easing.quad);
3434
3535const { width : SCREEN_WIDTH , height : SCREEN_HEIGHT } = Dimensions . get ( "screen" ) ;
3636
37+ const SCROLL_SETTLE_MS = 400 ;
38+ const VISIBLE_MARGIN = 16 ;
39+
3740export const TourOverlay = ( ) => {
38- const { getMeasurement, getConfig } = useTourContext ( ) ;
41+ const { getMeasurement, getConfig, getScrollRef } = useTourContext ( ) ;
3942 const isActive = useIOSelector ( isTourActiveSelector ) ;
4043 const groupId = useIOSelector ( activeGroupIdSelector ) ;
4144 const stepIndex = useIOSelector ( activeStepIndexSelector ) ;
@@ -44,6 +47,7 @@ export const TourOverlay = () => {
4447 const overlayRef = useRef < View > ( null ) ;
4548 const overlayOffsetRef = useRef ( { x : 0 , y : 0 } ) ;
4649 const isFirstMeasurement = useRef ( true ) ;
50+ const measureGeneration = useRef ( 0 ) ;
4751
4852 // Local visibility state: stays true during fade-out
4953 const [ visible , setVisible ] = useState ( false ) ;
@@ -94,6 +98,119 @@ export const TourOverlay = () => {
9498 [ ]
9599 ) ;
96100
101+ const scrollIntoViewIfNeeded = useCallback (
102+ async (
103+ gId : string ,
104+ itemIndex : number ,
105+ m : TourItemMeasurement ,
106+ generation : number
107+ ) : Promise <
108+ | { measurement : TourItemMeasurement ; didScroll : boolean ; stale : false }
109+ | { stale : true }
110+ > => {
111+ const { height : windowHeight } = Dimensions . get ( "window" ) ;
112+ const ref = getScrollRef ( gId ) ;
113+ if ( ! ref ) {
114+ return { measurement : m , didScroll : false , stale : false } ;
115+ }
116+
117+ const isAboveView = m . y + m . height < ref . headerHeight + VISIBLE_MARGIN ;
118+ const isBelowView = m . y > windowHeight - VISIBLE_MARGIN ;
119+ if ( ! isAboveView && ! isBelowView ) {
120+ return { measurement : m , didScroll : false , stale : false } ;
121+ }
122+
123+ // Fade out cutout before scrolling so it doesn't stay
124+ // visible at the old position while content moves
125+ if ( ! isFirstMeasurement . current ) {
126+ await new Promise < void > ( resolve => {
127+ cutoutOpacity . value = withTiming (
128+ 0 ,
129+ { duration : ANIMATION_DURATION , easing : STEP_EASING } ,
130+ ( ) => runOnJS ( resolve ) ( )
131+ ) ;
132+ } ) ;
133+ if ( measureGeneration . current !== generation ) {
134+ return { stale : true } ;
135+ }
136+ }
137+
138+ const currentScrollY = ref . scrollY . value as number ;
139+ const desiredWindowY = ref . headerHeight + VISIBLE_MARGIN ;
140+ const scrollTarget = Math . max ( 0 , currentScrollY + ( m . y - desiredWindowY ) ) ;
141+ ref . scrollViewRef . current ?. scrollTo ( { y : scrollTarget , animated : true } ) ;
142+
143+ await new Promise < void > ( resolve => setTimeout ( resolve , SCROLL_SETTLE_MS ) ) ;
144+ if ( measureGeneration . current !== generation ) {
145+ return { stale : true } ;
146+ }
147+
148+ const updated = await getMeasurement ( gId , itemIndex ) ;
149+ if ( measureGeneration . current !== generation ) {
150+ return { stale : true } ;
151+ }
152+ return updated
153+ ? { measurement : updated , didScroll : true , stale : false }
154+ : { measurement : m , didScroll : true , stale : false } ;
155+ } ,
156+ [ getScrollRef , getMeasurement , cutoutOpacity ]
157+ ) ;
158+
159+ const applyCutout = useCallback (
160+ (
161+ padded : TourItemMeasurement ,
162+ config : { title : string ; description : string } | undefined ,
163+ didScroll : boolean
164+ ) => {
165+ if ( isFirstMeasurement . current ) {
166+ // First step: position cutout immediately, then fade the overlay in
167+ isFirstMeasurement . current = false ;
168+ setMeasurement ( padded ) ;
169+ setTooltipConfig ( config ) ;
170+ cutoutX . value = padded . x ;
171+ cutoutY . value = padded . y ;
172+ cutoutW . value = padded . width ;
173+ cutoutH . value = padded . height ;
174+ cutoutOpacity . value = 1 ;
175+ opacity . value = withTiming ( 1 , { duration : ANIMATION_DURATION } ) ;
176+ } else if ( didScroll ) {
177+ // Already faded out before scrolling — reposition and fade in
178+ cutoutX . value = padded . x ;
179+ cutoutY . value = padded . y ;
180+ cutoutW . value = padded . width ;
181+ cutoutH . value = padded . height ;
182+ setMeasurement ( padded ) ;
183+ setTooltipConfig ( config ) ;
184+ cutoutOpacity . value = withTiming ( 1 , {
185+ duration : ANIMATION_DURATION ,
186+ easing : STEP_EASING
187+ } ) ;
188+ } else {
189+ // Normal step transition: fade out cutout, reposition, then fade back in
190+ const updateStepUI = ( ) => {
191+ setMeasurement ( padded ) ;
192+ setTooltipConfig ( config ) ;
193+ } ;
194+ cutoutOpacity . value = withTiming (
195+ 0 ,
196+ { duration : ANIMATION_DURATION , easing : STEP_EASING } ,
197+ ( ) => {
198+ cutoutX . value = padded . x ;
199+ cutoutY . value = padded . y ;
200+ cutoutW . value = padded . width ;
201+ cutoutH . value = padded . height ;
202+ runOnJS ( updateStepUI ) ( ) ;
203+ cutoutOpacity . value = withTiming ( 1 , {
204+ duration : ANIMATION_DURATION ,
205+ easing : STEP_EASING
206+ } ) ;
207+ }
208+ ) ;
209+ }
210+ } ,
211+ [ cutoutX , cutoutY , cutoutW , cutoutH , cutoutOpacity , opacity ]
212+ ) ;
213+
97214 const measureCurrentStep = useCallback ( async ( ) => {
98215 if ( groupId === undefined || items . length === 0 ) {
99216 return ;
@@ -103,14 +220,32 @@ export const TourOverlay = () => {
103220 return ;
104221 }
105222
106- const m = await getMeasurement ( groupId , currentItem . index ) ;
107- if ( ! m ) {
223+ measureGeneration . current += 1 ;
224+ const generation = measureGeneration . current ;
225+
226+ const initial = await getMeasurement ( groupId , currentItem . index ) ;
227+ if ( measureGeneration . current !== generation ) {
228+ return ;
229+ }
230+ if ( ! initial ) {
108231 requestAnimationFrame ( ( ) => {
109232 void measureCurrentStep ( ) ;
110233 } ) ;
111234 return ;
112235 }
113236
237+ const scrollResult = await scrollIntoViewIfNeeded (
238+ groupId ,
239+ currentItem . index ,
240+ initial ,
241+ generation
242+ ) ;
243+ if ( scrollResult . stale ) {
244+ return ;
245+ }
246+
247+ const { measurement : m , didScroll } = scrollResult ;
248+
114249 const offset = await measureOverlayOffset ( ) ;
115250
116251 const padded : TourItemMeasurement = {
@@ -121,53 +256,16 @@ export const TourOverlay = () => {
121256 } ;
122257
123258 const config = getConfig ( groupId , currentItem . index ) ;
124-
125- if ( isFirstMeasurement . current ) {
126- // First step: position cutout immediately, then fade the overlay in
127- isFirstMeasurement . current = false ;
128- setMeasurement ( padded ) ;
129- setTooltipConfig ( config ) ;
130- cutoutX . value = padded . x ;
131- cutoutY . value = padded . y ;
132- cutoutW . value = padded . width ;
133- cutoutH . value = padded . height ;
134- cutoutOpacity . value = 1 ;
135- opacity . value = withTiming ( 1 , { duration : ANIMATION_DURATION } ) ;
136- } else {
137- // Subsequent steps: fade out cutout, reposition, then fade back in
138- const updateStepUI = ( ) => {
139- setMeasurement ( padded ) ;
140- setTooltipConfig ( config ) ;
141- } ;
142- cutoutOpacity . value = withTiming (
143- 0 ,
144- { duration : ANIMATION_DURATION , easing : STEP_EASING } ,
145- ( ) => {
146- cutoutX . value = padded . x ;
147- cutoutY . value = padded . y ;
148- cutoutW . value = padded . width ;
149- cutoutH . value = padded . height ;
150- runOnJS ( updateStepUI ) ( ) ;
151- cutoutOpacity . value = withTiming ( 1 , {
152- duration : ANIMATION_DURATION ,
153- easing : STEP_EASING
154- } ) ;
155- }
156- ) ;
157- }
259+ applyCutout ( padded , config , didScroll ) ;
158260 } , [
159261 groupId ,
160262 items ,
161263 stepIndex ,
162264 getMeasurement ,
163265 getConfig ,
266+ scrollIntoViewIfNeeded ,
164267 measureOverlayOffset ,
165- cutoutX ,
166- cutoutY ,
167- cutoutW ,
168- cutoutH ,
169- cutoutOpacity ,
170- opacity
268+ applyCutout
171269 ] ) ;
172270
173271 useEffect ( ( ) => {
0 commit comments