Skip to content

Commit 39d6340

Browse files
committed
Add support for elements below the fold
1 parent f9b6d46 commit 39d6340

File tree

5 files changed

+274
-49
lines changed

5 files changed

+274
-49
lines changed

ts/components/ui/IOScrollViewWithLargeHeader.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useNavigation } from "@react-navigation/native";
1414
import { ComponentProps, forwardRef, ReactNode, useState } from "react";
1515

1616
import { LayoutChangeEvent, View } from "react-native";
17+
import Animated, { AnimatedRef } from "react-native-reanimated";
1718
import I18n from "i18next";
1819
import {
1920
BackProps,
@@ -48,6 +49,7 @@ type Props = WithTestID<
4849
ignoreAccessibilityCheck?: ComponentProps<
4950
typeof HeaderSecondLevel
5051
>["ignoreAccessibilityCheck"];
52+
animatedRef?: AnimatedRef<Animated.ScrollView>;
5153
topElement?: ReactNode;
5254
alwaysBounceVertical?: boolean;
5355
} & SupportRequestParams
@@ -76,6 +78,7 @@ export const IOScrollViewWithLargeHeader = forwardRef<View, Props>(
7678
excludeEndContentMargin,
7779
testID,
7880
ignoreAccessibilityCheck = false,
81+
animatedRef,
7982
topElement = undefined,
8083
alwaysBounceVertical
8184
},
@@ -116,6 +119,7 @@ export const IOScrollViewWithLargeHeader = forwardRef<View, Props>(
116119
return (
117120
<IOScrollView
118121
actions={actions}
122+
animatedRef={animatedRef}
119123
headerConfig={headerProps}
120124
snapOffset={titleHeight}
121125
includeContentMargins={false}

ts/features/settings/devMode/playgrounds/GuidedTourPlayground.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@ import {
55
IOColors,
66
VSpacer
77
} from "@pagopa/io-app-design-system";
8+
import { useEffect } from "react";
89
import { StyleSheet, View } from "react-native";
10+
import Animated, {
11+
useAnimatedRef,
12+
useScrollViewOffset
13+
} from "react-native-reanimated";
14+
import { useHeaderHeight } from "@react-navigation/elements";
915
import { IOScrollViewWithLargeHeader } from "../../../../components/ui/IOScrollViewWithLargeHeader";
1016
import { useIODispatch, useIOSelector } from "../../../../store/hooks";
1117
import { GuidedTour } from "../../../tour/components/GuidedTour";
18+
import { useTourContext } from "../../../tour/components/TourProvider";
1219
import {
1320
resetTourCompletedAction,
1421
startTourAction
@@ -19,13 +26,32 @@ const PLAYGROUND_GROUP_ID = "playground";
1926

2027
export const GuidedTourPlayground = () => {
2128
const dispatch = useIODispatch();
29+
const { registerScrollRef, unregisterScrollRef } = useTourContext();
2230
const isCompleted = useIOSelector(
2331
isTourCompletedSelector(PLAYGROUND_GROUP_ID)
2432
);
2533

34+
const scrollRef = useAnimatedRef<Animated.ScrollView>();
35+
const scrollY = useScrollViewOffset(scrollRef);
36+
const headerHeight = useHeaderHeight();
37+
38+
useEffect(() => {
39+
registerScrollRef(PLAYGROUND_GROUP_ID, scrollRef, scrollY, headerHeight);
40+
return () => {
41+
unregisterScrollRef(PLAYGROUND_GROUP_ID);
42+
};
43+
}, [
44+
registerScrollRef,
45+
unregisterScrollRef,
46+
scrollRef,
47+
scrollY,
48+
headerHeight
49+
]);
50+
2651
return (
2752
<IOScrollViewWithLargeHeader
2853
title={{ label: "Guided Tour Playground" }}
54+
animatedRef={scrollRef}
2955
includeContentMargins
3056
>
3157
<IOButton
@@ -92,6 +118,41 @@ export const GuidedTourPlayground = () => {
92118
<Body>Some helpful text that explains what this section does.</Body>
93119
</View>
94120
</GuidedTour>
121+
122+
{/* Spacer to push items below the fold */}
123+
<VSpacer size={48} />
124+
<View style={styles.filler} />
125+
<VSpacer size={48} />
126+
127+
<GuidedTour
128+
groupId={PLAYGROUND_GROUP_ID}
129+
index={3}
130+
title="Below the Fold"
131+
description="This item is below the fold. The tour should auto-scroll to reveal it."
132+
>
133+
<View style={styles.card}>
134+
<H6>Below the Fold Card</H6>
135+
<VSpacer size={4} />
136+
<Body>You need to scroll to see this card.</Body>
137+
</View>
138+
</GuidedTour>
139+
140+
<VSpacer size={24} />
141+
142+
<GuidedTour
143+
groupId={PLAYGROUND_GROUP_ID}
144+
index={4}
145+
title="Bottom Item"
146+
description="This is the last item, far down the page."
147+
>
148+
<View style={styles.infoBox}>
149+
<Body weight="Semibold">Bottom Item</Body>
150+
<VSpacer size={4} />
151+
<Body>The very last tour stop at the bottom of the page.</Body>
152+
</View>
153+
</GuidedTour>
154+
155+
<VSpacer size={48} />
95156
</IOScrollViewWithLargeHeader>
96157
);
97158
};
@@ -102,6 +163,11 @@ const styles = StyleSheet.create({
102163
borderRadius: 8,
103164
padding: 16
104165
},
166+
filler: {
167+
height: 600,
168+
backgroundColor: IOColors["grey-100"],
169+
borderRadius: 8
170+
},
105171
infoBox: {
106172
backgroundColor: IOColors["grey-50"],
107173
borderRadius: 8,

ts/features/tour/components/TourOverlay.tsx

Lines changed: 141 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ const STEP_EASING = Easing.inOut(Easing.quad);
3434

3535
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("screen");
3636

37+
const SCROLL_SETTLE_MS = 400;
38+
const VISIBLE_MARGIN = 16;
39+
3740
export 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

Comments
 (0)