weex-nuke
Version:
基于 Rax 、Weex 的高性能组件体系 ~~
568 lines (455 loc) • 12.6 kB
JSX
'use strict';
/** @jsx createElement */
import { Component, createElement, PropTypes, render } from 'rax';
import View from 'nuke-view';
import Text from 'nuke-text';
import Item from './item';
import ThemeProvider from 'nuke-theme-provider';
import stylesProvider from '../styles';
const { connectStyle } = ThemeProvider;
import { Animate, easeInOutQuad } from './animate';
const noop = () => { };
const VISIBLE_ITEM_COUNT = 5;
const MAX_TOUCH_PATH_LENGTH = 20;
const VELOCITY_DETERMINE_PERIOD = 100;
const MILLISECONDS = 1000;
const MIN_VELOCITY = 50;
const VELOCITY_DECREASE_RATE = 0.9;
const BOUNCE_RESISTANCE = 0.5;
const BOUNCE_RESISTANCE_DISTANCE = 200; // px
function getComputedStyleHeight(el) {
if (typeof window === 'undefined') {
return 0;
}
return parseFloat(window.getComputedStyle(el).height);
}
class PickerColumn extends Component {
static propTypes = {
value: PropTypes.any,
dataSource: PropTypes.array,
onChange: PropTypes.func,
labelMap: PropTypes.func,
valueMap: PropTypes.func,
};
static defaultProps = {
selectedKey: null,
dataSource: [],
onChange: noop,
labelMap,
valueMap,
};
static contextTypes = {
onUpdate: PropTypes.func,
onChange: PropTypes.func,
__picker__: PropTypes.bool,
selectedValue: PropTypes.any,
};
static childContextTypes = {
__column__: PropTypes.bool,
selectedValue: PropTypes.any,
};
constructor(props, context) {
super(props);
let value;
if (context.__picker__) {
value = context.selectedValue[props.index];
} else if ('value' in props) {
value = props.value;
} else {
value = props.selectedKey;
}
this.state = {
value,
};
this.bound = null;
this.isTracking = false;
this.isDragging = false;
this.currentScrollTop = 0;
this.touchPath = []; // use to calculate move velocity
['onChange', 'swipeStart', 'swipeMove', 'swipeEnd'].forEach((m) => {
this[m] = this[m].bind(this);
});
}
getChildContext() {
return {
__column__: true,
selectedValue: this.state.value,
};
}
componentWillReceiveProps(nextProps, nextContext) {
if (nextContext.__picker__) {
this.setState({
value: nextContext.selectedValue[nextProps.index],
});
} else if ('value' in nextProps) {
this.setState({
value: nextProps.value,
});
}
}
componentWillUpdate(nextProps, nextState) {
if (
nextProps.dataSource !== this.props.dataSource ||
JSON.stringify(nextProps.dataSource) !==
JSON.stringify(this.props.dataSource)
) {
this.changed = true;
}
}
componentDidMount() {
this.componentDidUpdate();
}
componentDidUpdate() {
// calculate scroll content bound
// inMatrix arg for config platform
if (!this.bound || this.changed) {
this.calculateBound();
}
// set scroll offset according to default value
this.setScroll(this.state.value);
this.changed = false;
}
calculateBound(props) {
props = props || this.props;
const { indicator } = this.refs;
const { children, dataSource } = props;
let top; let bottom; let itemHeight; let
length;
const halfOfVisibleCount = Math.floor(VISIBLE_ITEM_COUNT / 2);
itemHeight = getComputedStyleHeight(indicator);
length = dataSource.length;
bottom = itemHeight * halfOfVisibleCount;
top = -itemHeight * (length - (VISIBLE_ITEM_COUNT - halfOfVisibleCount));
this.bound = {
top,
bottom,
itemHeight,
};
}
setScroll(value) {
const { itemHeight, bottom } = this.bound;
const { valueMap, dataSource, children, keyMap } = this.props;
let index = 0;
let _children;
let item;
if (typeof value === 'undefined') {
value = dataSource[0].key;
}
for (let i = 0, l = dataSource.length; i < l; i++) {
if (keyMap(dataSource[i]).toString() === value.toString()) {
index = i;
item = dataSource[i];
break;
}
}
if (this.context.__picker__) {
this.context.onUpdate(item, this.props.index);
}
this.updateOffset(bottom - itemHeight * index);
}
startAnimate(stepFunc, callback, duration, easeFunc) {
this.stopAnimate();
return (this.animateId = Animate.start(
stepFunc,
callback,
duration,
easeFunc
));
}
stopAnimate() {
if (this.animateId) {
Animate.stop(this.animateId);
this.animateId = null;
}
}
isInBound(scrollTop) {
const { top, bottom } = this.bound;
if (scrollTop === undefined) {
scrollTop = this.currentScrollTop;
}
return !(scrollTop > bottom || scrollTop < top);
}
getBouncedDiff(scrollTop) {
const { top, bottom } = this.bound;
if (!this.isInBound(scrollTop)) {
if (scrollTop > bottom) {
return scrollTop - bottom;
}
if (scrollTop < top) {
return scrollTop - top;
}
}
return 0;
}
setTransformStyle(offset) {
const { content } = this.refs;
if (content) {
const style = `translate(0, ${offset}px)`;
// debugger; //bug fix: avoid translate3d, bug in android 4.3
content.style.WebkitTransform = style;
content.style.transform = style;
}
}
setOffset(top) {
this.currentScrollTop = top;
this.setTransformStyle(top);
}
updateOffset(top, animation, callback, easeFunc) {
let lastScrollTop; let
diff;
if (animation) {
(lastScrollTop = this.currentScrollTop), (diff = top - lastScrollTop);
this.startAnimate(
(percent) => {
this.setOffset(lastScrollTop + diff * percent);
},
() => {
this.setOffset(top);
callback && callback();
},
200,
easeFunc
);
} else {
this.setOffset(top);
callback && callback();
}
}
restrictScrollBound(scrollTop) {
const { top, bottom } = this.bound;
const { max, min } = Math;
return min(max(top, scrollTop), bottom);
}
// when touch move end or animation end,
// adjust scrollTop value to stop by nearest picker item
stopBy() {
const { abs } = Math;
// debugger;
const current = this.currentScrollTop;
const itemHeight = this.bound.itemHeight;
const remainder = current % itemHeight;
const multiple = parseInt(current / itemHeight, 10);
let stopBy = current;
if (abs(remainder) >= itemHeight / 2) {
// reserve the sign that represent scroll direction
stopBy = remainder / abs(remainder) * (abs(multiple) + 1) * itemHeight;
} else {
stopBy = multiple * itemHeight;
}
stopBy = this.restrictScrollBound(stopBy);
this.updateOffset(
stopBy,
false,
() => {
this.onChange();
},
easeInOutQuad
);
}
swipeStart(e) {
e.preventDefault();
const pageY = e.touches ? e.touches[0].pageY : e.clientY;
this.isTracking = true;
this.isDragging = false;
this.touchPageY = pageY;
this.lastScrollTop = this.currentScrollTop;
this.touchPath = [
{
time: Date.now(),
pageY,
},
];
this.stopAnimate();
}
swipeMove(e) {
e.preventDefault();
if (!this.isTracking) {
return;
}
this.isDragging = true;
const { abs, max, min } = Math;
const initPageY = this.touchPageY;
const currentPageY = e.touches ? e.touches[0].pageY : e.clientY;
let diff = currentPageY - initPageY;
let currentScrollTop = this.lastScrollTop + diff;
// bounce
if (!this.isInBound(currentScrollTop)) {
let bound; let
bounce;
// bounced diff
diff = this.getBouncedDiff(currentScrollTop);
bound = currentScrollTop - diff;
// apply bounce resistance
diff = min(
max(-BOUNCE_RESISTANCE_DISTANCE, diff),
BOUNCE_RESISTANCE_DISTANCE
);
bounce = diff * (1 - abs(diff) / (BOUNCE_RESISTANCE_DISTANCE * 2));
currentScrollTop = bound + bounce;
}
this.updateOffset(currentScrollTop);
// record move path
this.touchPath.push({
time: Date.now(),
pageY: currentPageY,
});
// restrict length
if (this.touchPath.length > MAX_TOUCH_PATH_LENGTH) {
this.touchPath = this.touchPath.slice(MAX_TOUCH_PATH_LENGTH / 2);
}
}
swipeEnd(e) {
e.preventDefault();
// Ignore event when tracking is not enabled (event might be outside of element)
if (!this.isDragging) {
return;
}
const { abs } = Math;
// velocity calculation
const lastTouchTime = Date.now();
const startPos = this.touchPath.length - 1;
let endPos = startPos;
let timeDiff = 0;
let velocity = 0;
for (
let i = startPos - 1;
i >= 0 && timeDiff < VELOCITY_DETERMINE_PERIOD;
i--
) {
const position = this.touchPath[i];
timeDiff = lastTouchTime - position.time;
endPos = i;
}
if (startPos !== endPos) {
const firstPageY = this.touchPath[endPos].pageY;
const lastPageY = this.touchPath[startPos].pageY;
velocity = (lastPageY - firstPageY) / timeDiff * MILLISECONDS;
}
if (abs(velocity) > MIN_VELOCITY) {
const animateId = this.startAnimate((percent, timeDiff, timeFrame) => {
const currentScrollTop = this.currentScrollTop;
velocity *= VELOCITY_DECREASE_RATE;
// bounce
if (!this.isInBound(currentScrollTop)) {
velocity *= BOUNCE_RESISTANCE;
}
const diff = velocity * timeFrame / MILLISECONDS;
this.setOffset(currentScrollTop + diff);
if (abs(velocity) < MIN_VELOCITY) {
Animate.stop(animateId);
this.stopBy();
}
});
} else {
this.stopBy();
}
this.isTracking = false;
this.isDragging = false;
}
setValue(value) {
if (!('value' in this.props)) {
this.setState({
value,
});
}
}
getValue() {
return this.state.value;
}
onChange(e) {
const scrollTop = this.currentScrollTop;
const { bottom, itemHeight } = this.bound;
const itemIndex = Math.round((bottom - scrollTop) / itemHeight);
const { index, children, dataSource, valueMap, keyMap } = this.props;
let value; let
item;
if (children) {
item = this.props.children[itemIndex];
value = item.props.value;
} else {
item = dataSource[itemIndex];
value = keyMap(item);
}
if (value === this.state.value) {
return;
}
if (this.context.__picker__) {
this.context.onChange(value, item, index);
} else {
this.setValue(value);
this.props.onChange(value, item);
}
}
render() {
const { value } = this.state;
const styles = this.props.themeStyle;
const {
dataSource,
labelMap,
valueMap,
className,
style = {},
prefix = this.defaultPrefix,
} = this.props;
let { children } = this.props;
if (!children) {
children = dataSource.map((item, index) => (
<Item key={keyMap(item)} value={valueMap(item)}>
{valueMap(item)}
</Item>
));
}
return (
<div
x="column-item"
style={[styles['picker-column'], style]}
onMouseDown={this.swipeStart}
onMouseMove={this.swipeMove}
onMouseUp={this.swipeEnd}
onMouseLeave={this.swipeEnd}
onTouchStart={this.swipeStart}
onTouchMove={this.swipeMove}
onTouchEnd={this.swipeEnd}
onTouchCancel={this.swipeEnd}
>
<div
ref="content"
x="column-item-scroll"
style={styles['picker-column-scroll']}
>
{children}
</div>
<div x="column-item-mask" style={styles['picker-column-mask']} />
<div
x="column-item-indicator"
ref="indicator"
style={styles['picker-column-indicator']}
/>
</div>
);
}
}
PickerColumn.displayName = 'Picker';
const StyledPickerColumn = connectStyle(stylesProvider)(PickerColumn);
export default StyledPickerColumn;
// for label map
export function labelMap(item) {
if (typeof item === 'object') {
return item.label;
}
return item;
}
// for value map
export function valueMap(item) {
if (typeof item === 'object') {
return item.value;
}
return item;
}
// for key map
export function keyMap(item) {
if (typeof item === 'object') {
return item.key;
}
return item;
}