动画是为一个app创造出色用户体验的重要组成部分。 它的关键挑战是向用户解释应用程序的逻辑,但是常见的错误是鲁莽地使用动画,从而否定了改善用户体验的整个观点。 为了使应用出色而不仅仅是出色或平庸,动画必须正确集成并且不应多余。
在本文中,您将了解如何使用ScrollView和react-native的Animated API创建标题动画。在文章结尾之后,我们将获得以下输出
How it works?
在ScrollView上渲染标头,并将ScrollView的顶部位置设置为标头的偏移量。然后,我们可以简单地使用ScrollView滚动位置插入标头。因此,让我们了解一下。
Let’s Code
我们使用Interpolate来更改位置,大小,颜色和Animated.event(),以将ScrollView位置与动画状态值进行映射。您可以阅读有关Interpolate(https://animationbook.codedaily.io/interpolation)和Animated.event(https://animationbook.codedaily.io/animated-event/)的更多详细信息。
始终在动画配置中设置useNativeDriver:true。通过跳过每个渲染帧的JS代码和本机代码之间的桥梁,可以使动画流畅。
- 由于React-Native的限制,seNativeDriver当前不支持绝对位置。为此,我们使用了transform属性。对于顶部,我们使用translateY,对于左边,我们使用translateX。
使用来自构造函数的动画值初始化状态scrollY,如下所示:
constructor(props) {
super(props);
this.state = {
scrollY: new Animated.Value(0)
};
}
现在我们不希望组件重新呈现,因此我们不使用setState()方法。我们使用ScrollView的onScroll属性来映射带有滚动位置的状态:
<Animated.ScrollView
overScrollMode={'never'}
style={{zIndex: 10}}
scrollEventThrottle={16}
onScroll={Animated.event(
[
{
nativeEvent: {contentOffset:{y:this.state.scrollY}}
}
]
)}>
//child here
</Animated.ScrollView>
Animated.event()方法使用ScrollView的Y偏移量映射状态scrollY表示垂直偏移量。
scrollEventThrottle 控制滚动时触发滚动事件的频率(以毫秒为单位的时间间隔)。 较小的数字可提高跟踪滚动位置的代码的准确性,但由于通过网桥发送的信息量大,因此可能导致滚动性能问题。 当JS运行循环同步到屏幕刷新率时,您不会注意到在1-16之间设置的值之间存在差异。
现在是时候对轮廓图像进行动画处理了,以借助Interpolation从屏幕中间到左侧标题进行动画处理。
//artist profile image position from left
_getImageLeftPosition = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 80, 140],
outputRange: [ThemeUtils.relativeWidth(30), ThemeUtils.relativeWidth(38), ThemeUtils.relativeWidth(10)],
extrapolate: 'clamp',
useNativeDriver: true
});
};
可以先通过插值来运行每个属性。 插值通常使用线性插值将输入范围映射到输出范围,但还支持缓动功能。 默认情况下,它将外推曲线超出给定的范围,但是您也可以将其钳制输出值。
在这里,从输入范围0–80,我们的图像将相对于设备宽度的位置从30更改为38,从80–140则将相对位置更改为38至10。
当状态值从我们之前完成的ScrollView的onScroll映射更改时调用的插值方法。
//artist profile image position from top
_getImageTopPosition = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [ThemeUtils.relativeHeight(20), Platform.OS === 'ios' ? 8 : 10],
extrapolate: 'clamp',
useNativeDriver: true
});
};
//artist profile image width
_getImageWidth = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [ThemeUtils.relativeWidth(40), ThemeUtils.APPBAR_HEIGHT - 20],
extrapolate: 'clamp',
useNativeDriver: true
});
};
//artist profile image height
_getImageHeight = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [ThemeUtils.relativeWidth(40), ThemeUtils.APPBAR_HEIGHT - 20],
extrapolate: 'clamp',
useNativeDriver: true
});
};
与左位置相同,我们将位置设置为top,根据ScrollView的滚动位置设置图像的高度和宽度。现在,以Animated.Image的样式设置这些值,如下所示:
render() {
const profileImageLeft = this._getImageLeftPosition();
const profileImageTop = this._getImageTopPosition();
const profileImageWidth = this._getImageWidth();
const profileImageHeight = this._getImageHeight();
return(
<Animated.Image
style={
[styles.profileImage, {
borderRadius: (ThemeUtils.APPBAR_HEIGHT - 20) / 2,
height: profileImageHeight,
width: profileImageWidth,
transform: [
{translateY: profileImageTop},
{translateX: profileImageLeft}
]
}]}
source={profileImage}
/>
);
}
现在,当用户滚动ScrollView时,将调用onScroll方法,我们的状态scrollY将更改,并且当stateY更改后,将调用内插法,并且完成了精美的动画。
在当前示例中,我使用了许多插值值,例如图像边框宽度,图像边框颜色,标题不透明度等。所有方法都在滚动位置映射的相同原理下工作。
最终代码:
import React, {Component, PureComponent} from 'react';
import {
Animated,
View,
StatusBar,
Text,
Image,
Platform,
StyleSheet,
Linking,
TouchableOpacity,
} from 'react-native';
/*Data*/
import artistData from './assets/data/SongData.json';
import MaterialAnimatedView from './MaterialAnimatedView';
import {HTouchable} from '../../components/HTouchable';
import {Navigation, Options} from 'react-native-navigation';
import {MyColors} from '../../config/Colors';
import {ThemeUtils} from './utils/ThemeUtils';
const ARTIST_NAME = 'Villa_Mou';
const coverImage = require('./assets/images/bg.png');
const profileImage = require('./assets/images/icon.png');
const backImage = require('./assets/images/back.png');
interface Props {
componentId: string;
}
interface State {
scrollY: any;
}
/**
* 图片高度 屏幕30%
*/
const HEADER_IMAGE_HEIGHT = ThemeUtils.relativeHeight(30);
export default class ArtistScreen extends PureComponent<Props, State> {
static options(): Options {
return {
topBar: {
visible: false,
},
statusBar: {drawBehind: true, style: 'light', backgroundColor: 'rgba(0,0,0,0.4)'},
};
}
constructor(props) {
super(props);
this.state = {
scrollY: new Animated.Value(0),
};
}
render() {
return (
<View style={styles.container}>
{this.renderHeaderImageBg()}
{this.renderHeader()}
{this.renderUserIcon()}
{this.renderVipCard()}
{this.renderList()}
</View>
);
}
private renderVipCard = () => {
const top = this._getVipCardTop();
const opacity = this._getVipCardOpacity();
return (
<Animated.View
style={{
height: 50,
backgroundColor: MyColors.BLACK,
position: 'absolute',
top: top,
left: 25,
right: 25,
justifyContent: 'center',
paddingLeft: 15,
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
opacity,
}}>
<Text style={{color: '#744307', fontWeight: 'bold', fontSize: 16}}>专属VIP卡</Text>
</Animated.View>
);
};
private renderList = () => {
const listViewTop = this._getListViewTopPosition();
const normalTitleOpacity = this._getNormalTitleOpacity();
return (
<Animated.ScrollView
overScrollMode={'never'}
style={{zIndex: 10}}
scrollEventThrottle={16}
onScroll={Animated.event([
{
nativeEvent: {contentOffset: {y: this.state.scrollY}},
},
])}>
<Animated.Text
style={[
styles.profileTitle,
{
opacity: normalTitleOpacity,
},
]}>
{ARTIST_NAME}
</Animated.Text>
<Animated.View
style={{
transform: [
{
translateY: listViewTop,
},
],
}}>
{artistData.map((item, index) => this.renderItem(index, item))}
</Animated.View>
</Animated.ScrollView>
);
};
private renderUserIcon = () => {
const profileImageLeft = this._getImageLeftPosition();
const profileImageTop = this._getImageTopPosition();
const profileImageWidth = this._getImageWidth();
const profileImageHeight = this._getImageHeight();
const profileImageBorderWidth = this._getImageBorderWidth();
const profileImageBorderColor = this._getImageBorderColor();
return (
<Animated.Image
style={[
styles.profileImage,
{
borderWidth: profileImageBorderWidth,
borderColor: profileImageBorderColor,
borderRadius: (ThemeUtils.APPBAR_HEIGHT - 20) / 2,
height: profileImageHeight,
width: profileImageWidth,
transform: [{translateY: profileImageTop}, {translateX: profileImageLeft}],
},
]}
source={profileImage}
/>
);
};
private renderHeaderImageBg = () => {
const headerImageOpacity = this._getHeaderImageOpacity();
return (
<Animated.Image
style={[
styles.headerImageStyle,
{
opacity: headerImageOpacity,
},
]}
source={coverImage}
/>
);
};
private renderHeader = () => {
const headerBackgroundColor = this._getHeaderBackgroundColor();
const headerTitleOpacity = this._getHeaderTitleOpacity();
return (
<View>
{Platform.OS === 'android' && (
<Animated.View
style={{
height: StatusBar.currentHeight,
backgroundColor: headerBackgroundColor,
}}
/>
)}
<Animated.View
style={[
styles.headerStyle,
{
backgroundColor: headerBackgroundColor,
},
]}>
<HTouchable
style={styles.headerLeftIcon}
onPress={() => {
Navigation.pop(this.props.componentId);
}}>
<Image source={backImage} />
</HTouchable>
<Animated.Text
style={[
styles.headerTitle,
{
opacity: headerTitleOpacity,
},
]}>
{ARTIST_NAME}
</Animated.Text>
</Animated.View>
</View>
);
};
renderItem = (index, item) => {
return (
<MaterialAnimatedView key={index.toString()} index={index}>
<HTouchable style={styles.artistCardContainerStyle} onPress={() => {}}>
<Image source={{uri: item.artistImage}} style={styles.artistImage} />
<View style={styles.cardTextContainer}>
<Text numberOfLines={1} style={styles.songTitleStyle}>
{item.songName}
</Text>
<Text numberOfLines={1}>{item.albumName}</Text>
</View>
</HTouchable>
</MaterialAnimatedView>
);
};
// scrollY从 0到 140 ,颜色从透明到黑色
_getHeaderBackgroundColor = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: ['rgba(0,0,0,0.0)', MyColors.BLACK],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//scrollY从 0到 140 ,透明度从1到0
_getHeaderImageOpacity = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [1, 0],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//artist profile image position from left
_getImageLeftPosition = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 80, 140],
outputRange: [
ThemeUtils.relativeWidth(35),
ThemeUtils.relativeWidth(38),
ThemeUtils.relativeWidth(12),
],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//artist profile image position from top
_getImageTopPosition = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [
ThemeUtils.relativeHeight(20),
Platform.OS === 'ios' ? 8 : 10 + StatusBar.currentHeight,
],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//artist profile image width
_getImageWidth = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [ThemeUtils.relativeWidth(30), ThemeUtils.APPBAR_HEIGHT - 20],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//artist profile image height
_getImageHeight = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [ThemeUtils.relativeWidth(30), ThemeUtils.APPBAR_HEIGHT - 20],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//artist profile image border width
_getImageBorderWidth = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [StyleSheet.hairlineWidth * 3, StyleSheet.hairlineWidth],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//artist profile image border color
_getImageBorderColor = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 140],
outputRange: [MyColors.CARD_BG_COLOR, 'rgba(255,255,255,1)'],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
_getVipCardTop = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 50],
outputRange: [ThemeUtils.relativeHeight(37), ThemeUtils.relativeHeight(30)],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
_getVipCardOpacity = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 50, 60],
outputRange: [1, 1, 0],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//Song list container position from top
_getListViewTopPosition = () => {
const {scrollY} = this.state;
//TODO: 高度待处理
return scrollY.interpolate({
inputRange: [0, 250],
outputRange: [ThemeUtils.relativeHeight(100) - ThemeUtils.relativeHeight(70), 0],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//header title opacity
_getHeaderTitleOpacity = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 20, 50],
outputRange: [0, 0.5, 1],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
//artist name opacity
_getNormalTitleOpacity = () => {
const {scrollY} = this.state;
return scrollY.interpolate({
inputRange: [0, 20, 30],
outputRange: [1, 0.5, 0],
extrapolate: 'clamp',
useNativeDriver: true,
});
};
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
},
/*header style*/
headerLeftIcon: {
position: 'absolute',
left: ThemeUtils.relativeWidth(2),
},
headerRightIcon: {
position: 'absolute',
right: ThemeUtils.relativeWidth(2),
},
headerStyle: {
height: ThemeUtils.APPBAR_HEIGHT,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
textAlign: 'center',
justifyContent: 'center',
color: MyColors.HEADER_TEXT_COLOR,
fontSize: ThemeUtils.fontNormal,
fontWeight: 'bold',
},
/*Top Image Style*/
headerImageStyle: {
height: HEADER_IMAGE_HEIGHT,
width: '100%',
top: 0,
alignSelf: 'center',
position: 'absolute',
},
/*profile image style*/
profileImage: {
position: 'absolute',
zIndex: 100,
},
/*profile title style*/
profileTitle: {
textAlign: 'center',
color: MyColors.white,
top: ThemeUtils.relativeHeight(29),
left: 0,
fontWeight: 'bold',
right: 0,
fontSize: 20,
},
/*song count text style*/
songCountStyle: {
position: 'absolute',
textAlign: 'center',
fontWeight: '400',
top: ThemeUtils.relativeHeight(40),
left: 0,
right: 0,
fontSize: ThemeUtils.fontNormal,
},
artistCardContainerStyle: {
backgroundColor: MyColors.CARD_BG_COLOR,
elevation: 5,
shadowRadius: 3,
shadowOffset: {
width: 3,
height: 3,
},
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 6,
marginBottom: 5,
marginHorizontal: 15,
flexDirection: 'row',
alignItems: 'center',
},
artistImage: {
height: ThemeUtils.relativeWidth(15),
width: ThemeUtils.relativeWidth(15),
borderRadius: ThemeUtils.relativeWidth(7.5),
borderWidth: 1,
},
songTitleStyle: {
fontSize: ThemeUtils.fontNormal,
color: MyColors.BLACK,
},
cardTextContainer: {
flex: 1,
margin: ThemeUtils.relativeWidth(3),
},
});
当然可能还存在部分适配问题,这个需要花时间去调一下,希望你喜欢这篇文章哦