功能模块的具体实现
一 , 图片的排版样式
对于每行最多 3 个, 不够 3 个按照 3 等距平分, 最终最多显示 9 个这样的排版方式. 可以考虑 flex 配合
flexDirection: "row"
以及 flexWrap: "wrap" 的策略来做.
但是, 这里有一个问题. 那就是我们需要做一个 3 行的图片排列方式, 同时还要满足当前行不足 3 张的时候按照等距去平分长度. 也就是是说, 不论我们最终显示多少图片, 我们的最终排版样式是都不会有缺口的, 我们都要把他填充完整. 那么, 如果我们单纯的使用 flex 配合
flexDirection: "row"
以及 flexWrap: "wrap" 来做的话, 我们必然要先获取设备屏幕的宽. 然后为每一个分配等额的比例尺寸, 然后在计算好每个之间的边距等等. 用一个完整的图片数据去遍历到这些已经准备好的容器中.
那么, 你就会发现, 每一个图片的大小都是固定的. 没有做到我们刚才想要的那个效果.
所以, 我们需要稍稍更换一个策略.
我们依然要用 flex 配合
flexDirection: "row"
以及 `flexWrap: "wrap". 现在我们要把数据分成 3 组来做(如果你是打算做 4 组, 那就分成 4 组). 我们可以确立 3 组空的数组, 然后根据传递来的数据, 按照满 3 个存成一组, 不满 3 个跟在后面的原则去切分这个数据.
然后分别去判断这 3 个组的内容是否存在, 再判断组中的数据数量, 按照比例分配不同的尺寸到每个图片的宽上(高是固定的). 组内数据越多(最多 3 个), 所分配出来的宽比重就越小(最少 1 个). 反之就越大.
- // 初始化数据
- constructor(props) {
- super(props);
- this.state = {
- imgLineA: [], // 第一行显示
- imgLineB: [], // 第二行显示
- imgLineC: [] // 第三行显示
- };
- }
- componentWillMount() {
- const { imgSource } = this.props; // image 数据源
- if (!isEmpty(imgSource)) {
- const _imgSize = imgSource.length;
- const _partSize = Math.ceil(_imgSize / 3);
- let _partArray = [];
- for (let i = 0, j = 1; i <_partSize; i++, j++) {
- // 以 3 个一组切分 image 数据源
- _partArray = _partArray.concat(imgSource.slice(i * 3, j * 3> imgSource.length ? imgSource.length : j * 3));
- // 分别装在 3 个容器当中
- if (i === 0) {
- this.setState({
- imgLineA: _partArray
- });
- } else if (i === 1) {
- this.setState({
- imgLineB: _partArray
- });
- } else if (i === 2) {
- this.setState({
- imgLineC: _partArray
- });
- }
- // 将临时容器置空
- _partArray = [];
- }
- }
- }
- render() {
- return(
- <View style={{flex:1}}>
- {
- isEmpty(this.state.imgLineA)
- ?null
- :
- <View style={styles.showImgView}>
- {
- imgSLineTop.map((imgData, key) => {
- return (
- <TouchableOpacity key={key}
- style={{flex: 1}}
- activeOpacity={0.8}
- onPress={() => this.props.imgClick(key)}>
- <ImageChild loadImgUrl={imgData} imgNum={imgSLineTop}/>
- </TouchableOpacity>
- );
- })
- }
- </View>
- }
- ...
- {
- picNum>= 0
- ?
- <View style={styles.visBaView}>
- <Text style={styles.visText}>
- {`+ ${picNum}`}
- </Text>
- </View>
- : null
- }
- </View>
- )
- }
复制代码
二, 加载时的样式和加载错误图片的替换
对于网络图片的加载, 这个过程在 js 中一定是一个异步任务, 属于耗时操作. 所以, 网络资源图片的获取速度跟其所在的网络位置, 请求时限和当前的网络状态有关. 为了更好的用户体验, 我们决定在开发的过程中加入一种保护.
当网络图片正在加载的时候, 显示 loading 动画图.
当网络图片加载完成的时候, 在容器位置添加显示的图片.
当网络图片加载错误的时候, 显示一个本地的默认错误图片.
那么, 就是在我们实现的时候可以以每一行为单位, 去分别加载. 当时在设计时单纯的考虑将每一个图片循环加载到 < Image />中, 利用 Image 的 onLoad 方法将还没有加载成功的显示 loading 动画, 利用 Image 的 onError 方法将加载失败的图片显示为默认代替图片. 但是, 这个中存在一个问题. 那就是 onLoad 和 onError 方法都是异步的, 在做加载资源判断的时候, 往往地址错误的图片会请求更长的时间. 因此, 同时存在的这俩个方法不能按照正确的加载正确或者错误的顺序返回, 这就存在一个问题. 那就是所有资源地址正确的图片会被先加载完成, 所有的资源地址错误的图片最后加载. 这就导致了显示的位置错乱.
鉴于此, 我们决定换一个策略.
我们决定单独封装一个图片组件在外面, 就单纯的接收每一个图片的资源地址, 显示图片应分配的长宽大小. 而在封装的图片组件内部, 做单独的加载逻辑判断. 为每一个被分配到的资源文件做判断和渲染.
这样的话, 就把一个类似集合的问题剥开分多个任务去分别处理了.
在组件的内部, 我们可以根据 onLoad 和 onError 来为这单个图片的显示做相应的处理.
- // ImageChild.js
- // 初始化状态
- state = {
- loadStatus: 'pending',
- imageVis: false,
- };
- // 资源图片加载成功
- handleImageLoaded() {
- this.setState({
- loadStatus: 'success',
- })
- }
- // 资源图片加载失败
- handleImageErrored() {
- this.setState({
- loadStatus: 'error',
- })
- }
- render() {
- const {loadStatus, imageVis} = this.state;
- const {imgNum, loadImgUrl} = this.props;
- // 资源图片加载失败时显示默认的错误图片
- if (loadStatus === 'error') {
- return (
- <Image
- source={require('../images/iv_default.png')}
- style={{
- width: window.width / imgNum.length - 5,
- height: window.width * 0.32,
- margin: 2
- }}
- resizeMode={'cover'}
- />
- )
- }
- return (
- <View>
- <Image
- style={{
- width: window.width / imgNum.length - 5,
- height: window.width * 0.32,
- margin: 2
- }}
- source={{uri: loadImgUrl}}
- resizeMode={'cover'}
- onProgress={this.handleImageProgress}
- onLoad={this.handleImageLoaded.bind(this)}
- onError={this.handleImageErrored.bind(this)}
- />
- // 正在加载时显示 loading 动画
- {
- !imageVis &&
- <View style={{
- width: window.width / imgNum.length - 5,
- height: window.width * 0.32,
- alignItems: 'center',
- justifyContent: 'center',
- marginTop: -window.width * 0.32,
- margin: 2
- }}>
- <ActivityIndicator
- color={'#666'}
- size={'large'}
- />
- </View>
- }
- </View>
- )
- }
复制代码
三, 点击任意图片逻辑
那么到目前为止, 基本的布局和加载显示就完成了. 我们已经做了一个最多 9 个每行 3 个按分配的 3 等距显示, 加载时显示 loading, 加载完成显示图片, 加载错误显示默认错误图片的组件. 剩下的就是点击一个加载完成的图片, 单独显示该图片详情(占满屏幕), 左右滑动可浏览相邻图片, 再次点击图片还原的功能.
对于点击图片查看详情, 我们这里实现的逻辑有:
点击图片时, 背景出现遮罩同时图片放大到屏幕对应尺寸.
点击图片时, 只有该图片放大.
图片放大后的容器.
当初, 在开发设计时, 我们想到可以用点击图片事件来改变背景颜色, 同时按比例放大图片 (到屏幕宽高尺寸). 但是, 在开发过程的 demo 尝试时, 发现这是一种不好的实现方式(只是单纯的一个实现思路, 没有考虑到性能). 在 ReactNative 的 render() 中是致命的. 因为这样的连续渲染极容易造成卡顿的感觉.
所以, 我采用了 modal 的策略.
当我点击其中一个图片的时候, 弹出一个全屏的 modal. 把应用操作层提到最上面的同时, 把下面的显示内容遮住.
那么, 我们就可以在这个遮罩中显示我们所点击的那个图片了.
对于如何把图片放大到屏幕大小? 并且保持原图片的宽高比例? 这个地方实现的方法有很多种. 我在这里参考了 Android 中 picasso 源码 http://square.github.io/picasso/ 的设计方式.
先利用 Image 的 getSize()这个方法, 将加载完成的图片的宽和高获取到, 图片资源地址错误的给默认的宽高, 顺序的暂存到一个数组中. 然后将宽设定为 100%(当前屏幕的宽度), 根据比例和已经设定好的屏幕宽度求出对应比例下的高度.
- constructor(props) {
- super(props);
- this.state = {
- imgVis: false,
- visPage: 0,
- _imgHeight: [],
- copyImgSource: [], // 为图片的高度空间设置一个存储空间
- sortKey: [],
- };
- }
- componentWillMount() {
- const {imgSource} = this.props;
- imgSource.forEach((urlImg, key) => {
- Image.getSize(urlImg, (oWidth, oHeight) => {
- this.state.copyImgSource.push(imgSource[key]);
- // 求出加载成功图片的高度, 并且把他们存在一个数组当中
- this.state._imgHeight.push(Math.ceil(window.width * (oHeight / oWidth)));
- this.state.sortKey.push(key);
- })
- })
- }
复制代码
四, 点击后的大图查看排版和基本的过渡动画
对于如何点击那个就能直接显示那个图片, 并且在左右滑动的时候, 我们可以浏览相邻的图片.
我们考虑的思路是在外部用 ScrollView 封装一个类似 ViewPager 这样的组件, 可以用来横向承载数组的容器. 然后我们在内部的对应到那个 key 的时候, 就把单独对应的这个图片抽出来显示.
我们在外部的 View 中, 把整个组件的排列方式设置为横向
flexDirection: "row"
在内部用 Animated.View 对容器中的图片做渲染. 根据点击的 key, 乘以传过来的 width 值, 来设置左边的 POS 距离. 然后将要显示的这一组图片遍历的显示进去.
Animated 的应用
panResponder 的使用
我们在左右滑动的过程中: 当向左边滑动一个图片, 右边那个挨着的图片 (如果还存在) 就会跟着显示出来. 让我们点击这个图片的时候, 这个图片的放大和缩小的过程以及透明度的变化, 都会给我们在用户体验上有很大的不同. 我们在这里尽量最求较为丝滑和更为舒服的操作体验.
所以, 我们给图片设置一组动画. 包括放大缩小, 透明度变化以及动画时间.
翻页图片的浏览, 少不了触摸滑动的配合. 这里简单介绍一下 panResponder 的基本用法和对于 Animated 的配合.
panResponder: 它可以将多点触控操作协调成一个手势. 它使得一个单点触摸可以接受更多的触摸操作, 也可以用于识别简单的多点触摸手势. 它提供了一个对触摸响应系统响应器的可预测包装. 对于每一个处理函数, 它在原生事件之外提供了一个新的 gestureState 对象.
对于 panResponder 的分析, 请看另一篇详细分析 https://hankinspan.github.io/2018/07/18/JavaScript-相关技巧总结之二/
当在页面上的滑动值 dx> dy, 也就是说横向移动的 X 轴的距离大于纵向移动的 Y 轴的距离的绝对值的时候, 我们认为成功触发了这个滑动, 并且我们根据当前滑动 X 轴的长度, 动态的向 POS 添加这个长度, 同时也在更新下一个图片的位置, 并把动画的值设置到相应的上面.
- // ImgScrollPage.js
- // 设定默认值
- static propTypes = {
- initPage: PropTypes.number,
- blurredZoom: PropTypes.number,
- blurredOpacity: PropTypes.number,
- animationDuration: PropTypes.number,
- pageStyle: PropTypes.object,
- onImgPageChange: PropTypes.func,
- deltaDelay: PropTypes.number,
- children: PropTypes.array.isRequired
- };
- static defaultProps = {
- initPage: 0,
- blurredZoom: 1,
- blurredOpacity: 0.8,
- animationDuration: 150,
- deltaDelay: 0,
- onImgPageChange: () => {
- }
- };
- state = {
- width: 0,
- height: 0
- };
- /**
- * 获取当前页面前面的总长度
- * @param pageNb
- * @returns {number}
- * @private
- */
- _getPosForPage(pageNb) {
- return -pageNb * this._imgSizeInterval;
- }
- /**
- * 动态获取当前显示页面的大小
- * @param offset
- * @param diff
- * @returns {number}
- * @private
- */
- _getPageForOffset(offset, diff) {
- let boxPos = Math.abs(offset / this._imgSizeInterval);
- let index;
- if (diff <0) {
- index = Math.ceil(boxPos);
- } else {
- index = Math.floor(boxPos);
- }
- if (index < 0) {
- index = 0;
- } else if (index> this.props.children.length - 1) {
- index = this.props.children.length - 1;
- }
- return index;
- }
- //panResponder 预设
- componentWillMount() {
- this._panResponder = PanResponder.create({
- onStartShouldSetPanResponder: (evt, gestureState) => {
- const dx = Math.abs(gestureState.dx);
- const dy = Math.abs(gestureState.dy);
- return (dx> this.props.deltaDelay && dx> dy);
- },
- onStartShouldSetPanResponderCapture: (evt, gestureState) => false,
- onMoveShouldSetPanResponder: (evt, gestureState) => {
- const dx = Math.abs(gestureState.dx);
- const dy = Math.abs(gestureState.dy);
- return (dx> this.props.deltaDelay && dx> dy);
- },
- onMoveShouldSetPanResponderCapture: (evt, gestureState) => false,
- onPanResponderGrant: (evt, gestureState) => {
- },
- onPanResponderMove: (evt, gestureState) => {
- let suffix = "x";
- this.state.pos.setValue(this._lastPos + gestureState["d" + suffix]);
- },
- onPanResponderTerminationRequest: (evt, gestureState) => true,
- onPanResponderRelease: (evt, gestureState) => {
- let suffix = "x";
- this._lastPos += gestureState["d" + suffix];
- let page = this._getPageForOffset(this._lastPos, gestureState["d" + suffix]);
- this.animateToPage(page);
- },
- onPanResponderTerminate: (evt, gestureState) => {
- },
- onShouldBlockNativeResponder: (evt, gestureState) => true
- });
- }
- /**
- * 滑动下一页时的变化效果 加载新页图片的高度和滑动到的位置
- * @param width
- * @param height
- * @private
- */
- _scrollNextPage = (width, height) => {
- this._imgPageSize = width;
- this._imgSizeInterval = width;
- let initPage = this.props.initPage || 0;
- if (initPage <0) {
- initPage = 0;
- } else if (initPage>= this.props.children.length) {
- initPage = this.props.children.length - 1;
- }
- this._currentPage = initPage;
- this._lastPos = this._getPosForPage(this._currentPage);
- let viewsScale = [];
- let viewsOpacity = [];
- for (let i = 0; i <this.props.children.length; ++i) {
- viewsScale.push(new Animated.Value(i === this._currentPage ? 1 : this.props.blurredZoom));
- viewsOpacity.push(new Animated.Value(i === this._currentPage ? 1 : this.props.blurredOpacity));
- }
- this.setState({
- width,
- height,
- pos: new Animated.Value(this._getPosForPage(this._currentPage)),
- viewsScale,
- viewsOpacity
- });
- };
- /**
- * 为滑动添加动画效果
- * @param page
- */
- animateToPage = (page) => {
- let animations = [];
- if (this._currentPage !== page) {
- animations.push(
- Animated.timing(this.state.viewsScale[page], {
- toValue: 1,
- duration: this.props.animationDuration
- })
- );
- animations.push(
- Animated.timing(this.state.viewsOpacity[page], {
- toValue: 1,
- duration: this.props.animationDuration
- })
- );
- animations.push(
- Animated.timing(this.state.viewsScale[this._currentPage], {
- toValue: this.props.blurredZoom,
- duration: this.props.animationDuration
- })
- );
- animations.push(
- Animated.timing(this.state.viewsOpacity[this._currentPage], {
- toValue: this.props.blurredOpacity,
- duration: this.props.animationDuration
- })
- );
- }
- let toValue = this._getPosForPage(page);
- animations.push(
- Animated.timing(this.state.pos, {
- toValue: toValue,
- duration: this.props.animationDuration
- })
- );
- Animated.parallel(animations).start();
- this._lastPos = toValue;
- this._currentPage = page;
- this.props.onImgPageChange(page);
- };
- render() {
- const {width, height} = this.state;
- // 通过宽和高的值简单判断是否为最后一张(或者第一张)
- if (!width && !height) {
- return (
- <View style={{flex: 1}}>
- <View
- style={styles.orgNoPage}
- onLayout={evt => {
- let width = evt.nativeEvent.layout.width;
- let height = evt.nativeEvent.layout.height;
- this._scrollNextPage(width, height);
- }}
- />
- </View>
- );
- }
- let containerStyle = {
- flex: 1,
- left: this.state.pos,
- paddingLeft: 0,
- paddingRight: 0,
- flexDirection: "row"
- };
- let imgPageStyle = {
- width: this._imgPageSize,
- marginRight: 0
- };
- return (
- <View style={styles.orgScrollView}>
- <Animated.View
- style={containerStyle}
- {...this._panResponder.panHandlers}
- >
- {
- this.props.children.map((imgSource, key) => {
- return (
- <Animated.View
- key={key}
- style={[{
- opacity: this.state.viewsOpacity[key],
- transform: [{scaleY: this.state.viewsScale[key]}]
- }, imgPageStyle, this.props.pageStyle]}
- >
- {imgSource}
- </Animated.View>
- );
- })
- }
- </Animated.View>
- </View>
- );
- }
复制代码
五, 点击时值的传递
其实到此为止, 我们想要的大部分内容都已经出来了. 只需要把这几个效果做相应的拼合就可以了. 事实上, 还是有很多事情要做的, 我们这里好像是只做了简易的 demo 介绍.
比如说: 一些单击时值的传递, 和尽量把不同的事情交给不同的组件去办. 从我介绍的这个结构来看, 其实整个组件是由俩大部分组成的.
图片排版组件
查看详情的浏览组件
其中, 在排版组件中还做了进一步的封装. 把一组图片数据交给单独的组件去处理, 细分到加载和显示是不是成功. 根据数据的情况来确定排版的结构.
其次, 在图片浏览中我们根据数据量的大小和点击图片传入的 key 值, 来分配前端内容长度和后续补充内容的长度. 同时根据 panResponder 的相关方法来动态的改变这俩个值, 动态的改变前后段长度以实现滑动浏览的效果. 同时把这些值同步到 Animated 中以实现更好的交互体验.
这个过程中, 有些基本值的传递和滑动时一些数据的改变, 动态的分配这些值的情况. 大体来说都是比较简单的.
前半结构主要是布局, 后半结构主要是数据处理和触发值的控制.
来源: https://juejin.im/post/5b5fdd855188251aa91dc6e9