记录react-mobile-picker使用过程中遇到的bug(ios,部分安卓触发touchcancel时发生)--系统源码也有风险
问题描述一:
在ios,部分安卓,在手指拖动选择的时候,程序切换(其他情况也行,主要能触发TouchCancel),导致触发TouchCancel,源码里面在重置滚动位置的时候,直接取用一个对象,并且我在抛出undefined的同时也设置了value和options,导致计算的时候出现NaN;
解决:需要解构出startScrollerTranslate,在picker抛出undefined时不要设置value值
场景:省市选择器,选择省份的时候同步市的选项(需要不断赋值,之前写的代码也有问题)
分析源码:
import React, { useState, useEffect } from "react"; // import RcPicker from "react-mobile-picker"; import RcPicker from "../RcMobilePicker"; import styles from "./index.module.scss"; import "./reset.scss"; import scrollLock from "../../utils/scroll-lock"; interface IProps { title?: string; tipMsg?: string; cancelTxt?: string; confirmTxt?: string; onCancel: () => void; onConfirm: (result: object) => void; optionGroups: object; valueGroups: object; onChange?: (name, value) => void; } export default function Picker(props: IProps) { useEffect(() => { scrollLock.enable(); return () => { scrollLock.disable(); }; }, []); const handleChange = (name, value) => { console.log(name, value, "handleChangehandleChange");
原先并没有判断value为空的情况,直接触发change,导致value设置部分值为undefined,也为后面的报错留下伏笔(堆栈溢出) if (!!value) { props.onChange && props.onChange(name, value); } }; return ( <div className={styles.pickerModalWrapper}> <div className={styles.pickerModalMask} onClick={() => props.onCancel()} ></div> <div className={`${styles.pickerModal} ${styles.pickerModalToggle}`}> <header> <div className={styles.cancelBtn} onClick={() => props.onCancel()}> {props.cancelTxt || "取消"} </div> <div className={styles.title}>{props.title || "请选择"}</div> <div className={styles.confirmBtn} onClick={props.onConfirm}> {props.confirmTxt || "确定"} </div> </header> {props?.tipMsg ? ( <div className={styles.tipsContainer}> <p className={styles.txt}>{props?.tipMsg}</p> </div> ) : null} <RcPicker optionGroups={props.optionGroups} valueGroups={props.valueGroups} onChange={handleChange} /> </div> </div> ); }
import React, {Component} from 'react'; import PropTypes from 'prop-types'; import './style.scss'; class PickerColumn extends Component { static propTypes = { options: PropTypes.array.isRequired, name: PropTypes.string.isRequired, value: PropTypes.any.isRequired, itemHeight: PropTypes.number.isRequired, columnHeight: PropTypes.number.isRequired, onChange: PropTypes.func.isRequired }; constructor(props) { super(props); this.state = { isMoving: false, startTouchY: 0, startScrollerTranslate: 0, ...this.computeTranslate(props) }; } componentWillReceiveProps(nextProps) { if (this.state.isMoving) { return; } this.setState(this.computeTranslate(nextProps)); } computeTranslate = (props) => { const {options, value, itemHeight, columnHeight} = props; let selectedIndex = options.indexOf(value); if (selectedIndex < 0) { // throw new ReferenceError(); console.warn('Warning: "' + this.props.name+ '" doesn\'t contain an option of "' + value + '".'); this.onValueSelected(options[0]); selectedIndex = 0; } return { scrollerTranslate: columnHeight / 2 - itemHeight / 2 - selectedIndex * itemHeight, minTranslate: columnHeight / 2 - itemHeight * options.length + itemHeight / 2, maxTranslate: columnHeight / 2 - itemHeight / 2 }; }; onValueSelected = (newValue) => { this.props.onChange(this.props.name, newValue); }; handleTouchStart = (event) => { const startTouchY = event.targetTouches[0].pageY; this.setState(({scrollerTranslate}) => ({ startTouchY, startScrollerTranslate: scrollerTranslate })); }; handleTouchMove = (event) => { event.preventDefault(); const touchY = event.targetTouches[0].pageY; this.setState(({isMoving, startTouchY, startScrollerTranslate, minTranslate, maxTranslate}) => { if (!isMoving) { return { isMoving: true } } let nextScrollerTranslate = startScrollerTranslate + touchY - startTouchY; if (nextScrollerTranslate < minTranslate) { nextScrollerTranslate = minTranslate - Math.pow(minTranslate - nextScrollerTranslate, 0.8); } else if (nextScrollerTranslate > maxTranslate) { nextScrollerTranslate = maxTranslate + Math.pow(nextScrollerTranslate - maxTranslate, 0.8); } return { scrollerTranslate: nextScrollerTranslate }; }); }; handleTouchEnd = (event) => { if (!this.state.isMoving) { return; } this.setState({ isMoving: false, startTouchY: 0, startScrollerTranslate: 0 }); setTimeout(() => { const {options, itemHeight} = this.props; const {scrollerTranslate, minTranslate, maxTranslate} = this.state; let activeIndex; if (scrollerTranslate > maxTranslate) { activeIndex = 0; } else if (scrollerTranslate < minTranslate) { activeIndex = options.length - 1; } else { activeIndex = - Math.floor((scrollerTranslate - maxTranslate) / itemHeight); } this.onValueSelected(options[activeIndex]); }, 0); }; handleTouchCancel = (event) => { if (!this.state.isMoving) { return; }
//原来写法,通过分析reactdom可以看到,这个值没有解构,但是在start的时候又有解构,暂不知道是否有具体用意?源码地址:https://github.com/adcentury/react-mobile-picker/blob/master/src/index.js
//this.setState(({startScrollerTranslate}) => ({
this.setState(({startScrollerTranslate}) => ({
isMoving: false,
startTouchY: 0,
startScrollerTranslate: 0,
scrollerTranslate: startScrollerTranslate
}));
};
handleItemClick = (option) => {
if (option !== this.props.value) {
this.onValueSelected(option);
}
};
renderItems() {
const {options, itemHeight, value} = this.props;
return options.map((option, index) => {
const style = {
height: itemHeight + 'px',
lineHeight: itemHeight + 'px'
};
const className = `picker-item${option === value ? ' picker-item-selected' : ''}`;
return (
<div
key={index}
className={className}
style={style}
onClick={() => this.handleItemClick(option)}>{option}</div>
);
});
}
render() {
const translateString = `translate3d(0, ${this.state.scrollerTranslate}px, 0)`;
const style = {
MsTransform: translateString,
MozTransform: translateString,
OTransform: translateString,
WebkitTransform: translateString,
transform: translateString
};
if (this.state.isMoving) {
style.transitionDuration = '0ms';
}
return(
<div className="picker-column">
<div
className="picker-scroller"
style={style}
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
onTouchCancel={this.handleTouchCancel}>
{this.renderItems()}
</div>
</div>
)
}
}
export default class Picker extends Component {
static propTyps = {
optionGroups: PropTypes.object.isRequired,
valueGroups: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
itemHeight: PropTypes.number,
height: PropTypes.number
};
static defaultProps = {
itemHeight: 36,
height: 216
};
renderInner() {
const {optionGroups, valueGroups, itemHeight, height, onChange} = this.props;
const highlightStyle = {
height: itemHeight,
marginTop: -(itemHeight / 2)
};
const columnNodes = [];
for (let name in optionGroups) {
columnNodes.push(
<PickerColumn
key={name}
name={name}
options={optionGroups[name]}
value={valueGroups[name]}
itemHeight={itemHeight}
columnHeight={height}
onChange={onChange} />
);
}
return (
<div className="picker-inner">
{columnNodes}
<div className="picker-highlight" style={highlightStyle}></div>
</div>
);
}
render() {
const style = {
height: this.props.height
};
return (
<div className="picker-container" style={style}>
{this.renderInner()}
</div>
);
}
}
原因:在滚动到临界状态(也就是省份的中间),官方给的demo用例也是可以试出来,就是滚动到中间,切换程序,再回来的时候会发现比没有滚动在其中一个省市上,而是停留在中间,再重新滚动是,会先再触发抛出一个undefined,随后恢复正常),切换程序,触发cancel,抛出undefined,外部程序接收undefined的时候,set入value,重新遍历出options,picker组件接收到错误值;回到picker组件中重新滚动(handleTouchStart),结构出来的scrollerTranslate还是undefined/NaN,往下计算后又抛出undefined,导致死循环。
这里死循环是组件代码和自身代码都有问题导致的。
解决:抛出undefined不处理,picker在touchcancel的时候结构出startScrollerTranslate,这种原先的值,恢复到上次滚动的距离。
问题描述二(样式问题):没有做好兼容性,导致文字被遮挡,不影响功能,但丑
机型:苹果14 IOS16.4 升级了iosbeta版本好像都有问题safari
// 覆盖picker的渐变色 :global { .picker-container .picker-inner { -webkit-mask-box-image: none; } }
https://github.com/adcentury/react-mobile-picker/issues/56