##やりたかったこと
ヘッダー下にある検索タブを下にスクロールすると表示されずに、上にスクロールしたら表示されるような動きする
やってみる
表示
- スタイルやレイアウトなどは極力省いて、基本的にアニメーションの部分を載せます。
まずは検索タブとスクロールビュー内のメインビューが共に表示されているようにします。
ただ、タブはスクロールでどの部分が表示されていても表示可能にするために普通に並べるのではなく、position: absoluteを指定して、メインビューはmarginTopを指定します。
// 省略
<View style={styles.searchTab}> // 検索タブ
//省略
</View>
<ScrollView>
<View style={styles.main}> // メイン
// 省略
<View>
</ScrollView>
// 省略
export const SEARCH_TAB_HEIGHT = 80;
export const styles = StyleSheet.create({
searchTab: {
height: SEARCH_TAB_HEIGHT,
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
main: {
marginTop: SEARCH_TAB_HEIGHT
}
})
検索タブを動かす
このままだと、下にスクロールしたときに検索タブに回り込む形でスクロールされてしまうので、検索タブ自体も動かす必要があります。
なので、Animated API, transformなどを使いアニメーションとしてこれを実現します。
// 省略
const scrollY = useRef(new Animated.Value(0)).current;
const transformY = scrollY.interpolate({
inputRange: [0, SEARCH_TAB_HEIGHT],
outputRange: [0, -SEARCH_TAB_HEIGHT],
extrapolate: 'clamp',
});
<Animated.View style={{
...styles.searchTab,
transform: [{translateY: transformY}]
}}> // 検索タブ
</Animated.View>
<ScrollView
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {y: scrollY}}}],
{
useNativeDriver: false,
},
)}>
<View style={styles.main}> // メイン
<View>
</ScrollView>
// 省略
上のコードの
const scrollY = useRef(new Animated.Value(0)).current;
は、アニメーションに必要な値を設定してくれるオブジェクトを作成しています。
RNではこれをAnimated.Viewなどのアニメーション化されたコンポーネントのスタイルの属性の一つに指定し、これを更新していくことでアニメーションを実現することができます。
公式ドキュメントの一部分抜粋
Animated.Value can bind to style properties or other props, and can be interpolated as well. A single Animated.Value can drive any number of properties.
The core workflow for creating an animation is to create an Animated.Value, hook it up to one or more style attributes of an animated component, and then drive updates via animations using Animated.timing().
そのコードの下の
const transformY = scrollY.interpolate({
inputRange: [0, SEARCH_TAB_HEIGHT],
outputRange: [0, -SEARCH_TAB_HEIGHT],
extrapolate: 'clamp',
});
は、interpolate()を使ってAnimated.Valueで生成された値に何かしらの入力があった場合の出力の指定を可能にしています。
ここではscrollYに0の入力があった場合0が対応付けられ、SEARCH_TAB_HEIGHT、つまり80が入力された場合は-80が対応づけられます。
interpolate()は0か80かのように一点だけではなく、その間のレンジにも対応できるので、例えばこの場合では10が入力された場合、-10が対応付くことになります。
The interpolate() function allows input ranges to map to different output ranges.
interpolate() supports multiple range segments as well
また、extrapolate: 'clamp'を記述することで指定した範囲を超えた場合、それをクランプします。
ScrollViewに渡されている
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {y: scrollY}}}],
{
useNativeDriver: false,
},
)}
は、Animated.event()を使うことで、Animated.Valueの値(scrollY)にイベントオブジェクトの値を直接マップさせることを可能にしています。
ここでは、スクロールしたときに発生するイベントオブジェクトのnativeEvent.contentOffset.yの値をマップしています。
この独特(?)な構文はこれを実現するためのものなので、こういうものだと思って使うのがいいと思います。
Gestures, like panning or scrolling, and other events can map directly to animated values using Animated.event. This is done with a structured map syntax so that values can be extracted from complex event objects. The first level is an array to allow mapping across multiple args, and that array contains
nested objects.
こうすることで、下にスクロールするたびにscrollYがその時点のcontentOffset.yにマップされます。
そうすると、scrollY.interpolate()が実行されtransformYにその時のscrollYに応じて、指定した値がマップされます。
Animated.Viewには
transform: [{translateY: transformY}]
を渡しているので、transformYに応じて(ここでは上に)動くようになります。
スクロールした方向によって検索タブを表示させたりさせなかったりする
このままだと、ただ検索タブが消えるだけなので、表示できるようにします。
スクロールをした方向は、その時点のcontentOffset.yとスクロールしたあとのcontentOffset.yを比較すればわかるので、比較できるように新しいrefを作ります。
また、refはonScrollEndDragイベントのハンドラで更新していきます。
// 省略
const scrollY = useRef(new Animated.Value(0)).current;
const offsetY = useRef(0); //ref追加
const transformY = scrollY.interpolate({
inputRange: [0, SEARCH_TAB_HEIGHT],
outputRange: [0, -SEARCH_TAB_HEIGHT],
extrapolate: 'clamp',
});
<Animated.View style={{
...styles.searchTab,
transform: [{translateY: transformY}]
}}> // 検索タブ
</Animated.View>
<ScrollView
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {y: scrollY}}}],
{
useNativeDriver: false,
},
)}
//イベントを追加
onScrollEndDrag={(e) => {
if (
e.nativeEvent.contentOffset.y > offsetY.current
) {
offsetY.current = e.nativeEvent.contentOffset.y;
} else if (e.nativeEvent.contentOffset.y < offsetY.current) {
Animated.timing(scrollY, {
toValue: 0,
duration: 800,
useNativeDriver: false,
}).start();
offsetY.current = e.nativeEvent.contentOffset.y;
}
}}>
<View style={styles.main}> // メイン
<View>
</ScrollView>
// 省略
下にスクロールした場合は、おそらく基本的にcontentOffset.yは大きくなっていると思うので、
e.nativeEvent.contentOffset.y > offsetY.current
で表せます。
ここでは下スクロールの場合、検索タブは表示させないので
offsetY.current = e.nativeEvent.contentOffset.y;
でrefをoffset.yで更新するだけにします。
逆の場合は
Animated.timing(scrollY, {
toValue: 0,
duration: 800,
useNativeDriver: false,
}).start();
を実行しています。
Animatid.timing()はdurationで指定した時間に渡ってtoValueで指定した値をマップすることができます。
この場合は800ミリ秒かけて0がマップされることになります。
Animated provides several animation types, the most commonly used one being Animated.timing(). It supports animating a value over time using one of various predefined easing functions, or you can use your own.
おそらくこれでスクロール方向によって、タブが出てきたり出なかったりすると思います。
表示させるスピードを統一する
このままだとタブの表示スピードがその時のscrollYにマップされている値によって変わってしまいます。
例えば800がマップされていた場合と80がマップされていた場合を考えると、800 -> 0 と 80 -> 0 を共に800ミリ秒かけて行うとすると、前者の方がスピードが速くなります。
なのでこれを合わせたいと思います。
まずscrollYの値の上限を決めます。
scrollYはSEARCH_TAB_HEIGHTより上、つまり80より上の値をマップされる必要がないので、onScrollの際に、contentOffse.yが80より大きかったら80がマップされるようにします。
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {y: scrollY}}}],
{
useNativeDriver: false,
// listenerを追加
listener: (e) => {
if (e.nativeEvent.contentOffset.y > SEARCH_TAB_HEIGHT) {
scrollY.setValue(SEARCH_TAB_HEIGHT);
}
},
},
)}
次にdurationを変更します。
// 追加
const calculateDuration = useCallback((n: number) => {
return n * 5;
}, []);
Animated.timing(scrollY, {
toValue: 0,
// durationを変更
duration: calculateDuration(
offsetY.current > 80 ? 80 : offsetY.current,
),
useNativeDriver: false,
}).start();
offset.currentが81以上の場合は、scrollYは80がマップされているので80を渡し、それ以外の場合はcurrentを渡しています。
これでgifにあるような動きを実現できました。
何かミスなどありましたらコメントいただけると嬉しいです。
ありがとうございました。