记录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

浙公网安备 33010602011771号