지난번에 gesture handler, reanimated 라이브러리를 사용해 top sheet를 직접 구현해 보았는데, 오늘은 top sheet가 확장/축소 됐을 때 다른 컨텐츠를 보여주고 하단에 자연스럽게 스크롤이 생기게 적용했다.
1. 컨텐츠 배치
top sheet 아래 부분에 메인 컨텐츠가 표시되어야 하므로 Section 컴포넌트를 ScrollView로 묶어주었다.
<>
<TopSheet />
<ScrollView style={styles.scrollViewContainer}>
<Section1 />
<Section2 />
</ScrollView>
</>
현재 상태(top sheet가 축소된 상태)에서는 Section에 스크롤이 생기지 않는다.
하지만 확장되면 늘어난 top sheet의 높이만큼 스크롤이 생긴다.
2. 확장/축소 컨텐츠
위 사진처럼 서로 다른 컨텐츠를 자연스럽게 변환하기 위해 몇 가지 기능을 추가해야 했다.
2-1. useSharedValue
첫 번째로는 opacity를 사용한 전환, top sheet가 얼마나 확장되고 축소되는지에 따라 opacity를 적용해야 하므로 height의 추적이 필요하다.
// 최소 높이 = 축소
const minSheetHeight = height / 6;
// 최대 높이 = 확장
const maxSheetHeight = height / 2;
// top sheet height 공유
const sheetHeight = useSharedValue<number>(minSheetHeight);
// gesture y 좌표
const context = useSharedValue<{y: number}>({y: 0});
top sheet의 최소/최대 높이를 설정하고, 동적 style 적용을 위한 현재 top sheet의 height와 드래그가 시작되었을 때 y 값을 측정하기 위해 두 값을 useSharedValue로 생성한다.
2-2. useAnumatedStyle
두 번째는 선언된 공유 값을 활용해서 동적 style을 생성한다.
// top sheet의 높이를 변경
const animatedStyles = useAnimatedStyle(() => {
return {
height: sheetHeight.value,
};
});
// 축소된 top sheet에 적용할 동적 style
const animatedOpacityShort = useAnimatedStyle(() => {
const minSheetHeight = height / 6;
const maxSheetHeight = height / 2.4;
const opacityRange = maxSheetHeight - minSheetHeight;
let opacity: number = (maxSheetHeight - sheetHeight.value) / opacityRange;
opacity = Math.max(0, Math.min(opacity, 1));
const zIndex: number = opacity === 1 ? 10 : -10;
return {
opacity,
zIndex,
} as { opacity: number; zIndex: number };
});
// 확장된 top sheet에 적용할 동적 style
const animatedOpacityLong = useAnimatedStyle(() => {
const opacityRange = maxSheetHeight - minSheetHeight;
let opacity: number = (maxSheetHeight - sheetHeight.value) / opacityRange;
opacity = 1 - Math.max(0, Math.min(opacity, 1));
return {
opacity,
} as { opacity: number;};
});
opacity 값은 최대 높이(maxSheetHeight)에서 현재 높이(sheetHeight.value)를 뺀 값이 되는데, animatedOpacityShort에 maxSheetHeight값을 다르게 설정한 이유는 top sheet가 90%쯤 확장되었을 때 opacity가 0이 되었으면 해서이다.
(height / 2 > height / 2.4)
maxSheetHeight값을 원래 높이(height / 2)와 같게 설정하면, 100% 확장(height / 2)되었을 때 opacity가 0이 되고 더 작은 값(height / 2.4)으로 설정하게 되면 height / 2.4 지점에 도달했을 때 opacity가 0이 되는 것이다.
2-3. Gesture
그리고 마지막으로 gesture에 대한 핸들러 함수를 추가해야 한다.
const gesture = Gesture.Pan()
// 드래그 시작
.onStart(() => {
context.value = {y: sheetHeight.value};
})
// 드래그 중
.onUpdate((e) => {
const newValue = e.translationY + context.value.y;
sheetHeight.value = Math.min(
Math.max(newValue, minSheetHeight),
maxSheetHeight,
);
})
// 드래그 종료
.onEnd((e) => {
if (e.velocityY > 0) {
sheetHeight.value = withSpring(maxSheetHeight, {
damping: 10,
stiffness: 400,
overshootClamping: true,
});
} else {
sheetHeight.value = withSpring(minSheetHeight, {
damping: 10,
stiffness: 400,
overshootClamping: true,
});
}
});
return (
<Animated.View style={[styles.containerOut, animatedStyles]}>
<GestureHandlerRootView>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.container, animatedStyles]}>
<TopSheetShort
animatedOpacityShort={animatedOpacityShort}
/>
<TopSheetLong
animatedOpacityLong={animatedOpacityLong}
/>
<TopSheetBtn toggleHeight={toggleHeight} />
</Animated.View>
</GestureDetector>
</GestureHandlerRootView>
</Animated.View>
);
onStart에서 드래그가 시작될 때 sheetHeight의 값을 저장하고, onUpdate에서 손가락이 움직인 y축 거리만큼 sheetHeight에 더해주게 된다. 범위는 초기에 설정한 top sheet max/minSheetHeight안에서만 유효하다.
onEnd 핸들러는 사용자가 위로 드래그(e.velocityY < 0)했는지 또는 아래로 드래그(e.velocityY > 0)했는지 감지해 손가락을 도중에 놓더라도 확장/축소가 가능하게 한다.
damping, stiffness, overshootClamping은 react native reanimated 라이브러리에서 지원하는 옵션들이다.
각각 애니메이션 정지 속도, 애니메이션 속도, 반동을 의미한다.
그 외 옵션과 자세한 내용은 라이브러리 페이지에서 확인 가능
https://docs.swmansion.com/react-native-reanimated/docs/animations/withSpring
top sheet에 관한 자료나 내용이 없다 보니 필요한 분들에게 도움이 되었으면 하는 마음으로 마칩니다.
해당 포스팅에서 사용한 예제 소스코드는 아래 repositorie에서 확인 가능합니다.
* 만약 해당 코드를 직접 사용하고자 한다면, gesture handler, reanimated 라이브러리를 사용하기 위해 초기 설정, 네이티브 설정이 필요하므로 꼭 확인해 주시길 바랍니다.
https://github.com/yeonhub/react-native-top-sheet