A mature component library is usually composed of dozens of commonly used UI components, including both basic components such as buttons and Input boxes, as well as complex components such as tables, date pickers and Carousel.
Here we propose the concept of component complexity. The main source of a component’s complexity is its own state, that is, how many states a component needs to maintain independently of external input. Refer to the dumb Component and smart Component mentioned in the previous article. The difference is whether you need to maintain state inside the component that doesn’t depend on external input.
Real case – rotation components
In this article, we will take the Carousel component as an example and restore step by step how to achieve a smooth interactive Carousel component.
The simplest multicast component
All complexity aside, the essence of a multicast component is to switch between different elements in a fixed area. With this in mind, we can design the basic DOM structure of the multicast component as follows:
<Frame>
<SlideList>
<SlideItem />
...
<SlideItem />
</SlideList>
</Frame>Copy the code
As shown below:
Frame is the real display area of the multicast component, whose width and height are internally determined by the SlideItem entered by the user. One thing to note here is that you need to set the overflow property of the Frame to hidden, that is, to hide the portion of the Frame that is beyond its width and height, showing only one SlideItem at a time.
SlideList is the track container of the multicast component. By changing the value of translateX, you can slide in the track to display different multicast elements.
SlideItem is a layer of abstraction of the user-input rotation element, which can contain DOM elements such as IMG or div without affecting the logic of the rotation component itself.
Implement the switch before the rotation element
To switch between slideItems, we need to define the first internal state of the multicast component, currentIndex, which is the index value of the currently displayed multicast element. As mentioned above, changing SlideList’s translateX is the key to achieve rotation element switching, so we need to match currentIndex with SlideList’s translateX, i.e.
translateX = -(width) * currentIndexCopy the code
Width is the width of a single rotation element, which is the same as the width of Frame, so we can get the width of Frame at componentDidMount and use that to figure out the total width of the track.
componentDidMount() {
const width = get(this.container.getBoundingClientRect(), 'width');
}
render() {
const rest = omit(this.props, Object.keys(defaultProps));
const classes = classnames('ui-carousel', this.props.className);
return( <div {... rest} className={classes} ref={(node) => { this.container = node; }} > {this.renderSildeList()} {this.renderDots()} </div> ); }Copy the code
So far, we only need to change currentIndex in the caroute component to indirectly change the translateX of SlideList, so as to realize the switch between caroute elements.
Responding to user actions
As a common universal component, round-robin is widely used in both desktop and mobile terminals. Here, we first take the mobile terminal as an example to explain how to respond to user operations.
{map(children, (child, i) => (
<div
className="slideItem"
role="presentation"
key={i}
style={{ width }}
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
>
{child}
</div>
))}Copy the code
On the move side, we need to listen for three events that respond to the start of a slide, the middle of a slide, and the end of a slide. The start and end of the slide are one-off events, while the slide is a persistent event, so we can determine which values we need to determine in the three events.
The slide began
- StartPositionX: indicates the starting position of the slide
handleTouchStart = (e) => {
const { x } = getPosition(e);
this.setState({
startPositionX: x,
});
}Copy the code
In the sliding
- MoveDeltaX: Real-time distance of this slide
- Direction: Real-time sliding direction
- TranslateX: Real-time position of the track in this slide for rendering
handleTouchMove = (e) => {
const { width, currentIndex, startPositionX } = this.state;
const { x } = getPosition(e);
const deltaX = x - startPositionX;
const direction = deltaX > 0 ? 'right' : 'left';
this.setState({
moveDeltaX: deltaX,
direction,
translateX: -(width * currentIndex) + deltaX,
});
}Copy the code
End of the slide
- CurrentIndex: New currentIndex after this slide
- EndValue: translateX of the track after the end of the slide
handleTouchEnd = () => {
this.handleSwipe();
}
handleSwipe = () => {
const { children, speed } = this.props;
const { width, currentIndex, direction, translateX } = this.state;
const count = size(children);
let newIndex;
let endValue;
if (direction === 'left') { newIndex = currentIndex ! == count ? currentIndex + 1 : START_INDEX; endValue = -(width) * (currentIndex + 1); }else{ newIndex = currentIndex ! == START_INDEX ? currentIndex - 1 : count; endValue = -(width) * (currentIndex - 1); } const tweenQueue = this.getTweenQueue(translateX, endValue, speed); this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex)); }Copy the code
Because we update the track’s translateX in real time during a swipe, our wheel cast component can have a hand-following user experience, i.e. in a single swipe, the wheel cast element slides to the left or right as the user moves.
Achieve smooth switching animation
After implementing the user experience of following through on a swipe, we also need to position the displayed rotation element to the new currentIndex after the swipe. Depending on where the user is swiping, we can add +1 or -1 to the current currentIndex to get the new currentIndex. But the new currentIndex needs to be updated to either the last or the first when the first element is swiped left or the last element is swiped right.
The logic here is not complicated, but it does lead to a user experience problem that is very difficult to solve, that is, suppose we have 3 rotation elements, each rotation element is 300px wide, that is, when the last element is displayed, the translateX of the track is -600px, after we slide the last element to the left, The orbital translateX will be redefined to 0px, if we use the native CSS animation:
transition: 1s ease-in-out;Copy the code
The track will slide from left to right to the first cast element in a second, which is counter-intuitive because a user swipe left results in a right animation and vice versa.
This problem has plagued many front-end developers since ancient times, and I’ve seen several solutions to it:
- Define the track width as an infinite length (a few million px) that repeats a finite number of rotation elements an infinite number of times. This solution is obviously a hack and does not substantially solve the problem of the multicast component.
- Render only three rotation elements, the previous one, the current one, and the next, updating all three elements at the same time after each slide. This solution is complicated to implement because the number of states maintained within the component has increased from one currentIndex to three DOM elements with their own states, and performance is poor due to the constant deletion and addition of DOM nodes.
Let’s think about the nature of sliding again. Except for the first and last two elements, the new translateX value of all middle elements is fixed, i.e. -(Width * currentIndex). Animations in this case can be easily implemented perfectly. How do we achieve a smooth transition animation when the last element slides left because the translateX of the track has reached its limit?
Here we choose to concatenate the last element and the first element at the end of the track, respectively, to ensure a smooth transition animation without changing the DOM structure:
In this way, we have unified the calculation method of endValue after each slide, namely
// left
endValue = -(width) * (currentIndex + 1)
// right
endValue = -(width) * (currentIndex - 1)Copy the code
Implement high performance animations using requestAnimationFrame
RequestAnimationFrame is a browser-provided API that focuses on implementing animations. For those interested, check out the React Motion Slow Function Anatomy column again.
All animations are essentially a series of values on the timeline, specifically in the case of rotation scenes, namely: Start with the value when the user stops sliding and end with the value of translateX when the new currentIndex is translateX. Calculate the value of translateX for each frame of animation and finally get an array according to the user’s buffer function within the animation time set by the user (e.g. 0.5 seconds). Update the track’s style property at 60 frames per second. Each update consumes one intermediate value in the array of animated values until all intermediate values in the array are consumed, the animation ends and the callback is triggered.
The specific code is as follows:
const FPS = 60;
const UPDATE_INTERVAL = 1000 / FPS;
animation = (tweenQueue, newIndex) => {
if (isEmpty(tweenQueue)) {
this.handleOperationEnd(newIndex);
return;
}
this.setState({
translateX: head(tweenQueue),
});
tweenQueue.shift();
this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex));
}
getTweenQueue = (beginValue, endValue, speed) => {
const tweenQueue = [];
const updateTimes = speed / UPDATE_INTERVAL;
for (let i = 0; i < updateTimes; i += 1) {
tweenQueue.push(
tweenFunctions.easeInOutQuad(UPDATE_INTERVAL * i, beginValue, endValue, speed),
);
}
return tweenQueue;
}Copy the code
In the callback function, the new stable state value of the component is determined uniformly according to the change logic:
handleOperationEnd = (newIndex) => {
const { width } = this.state;
this.setState({
currentIndex: newIndex,
translateX: -(width) * newIndex,
startPositionX: 0,
moveDeltaX: 0,
dragging: false,
direction: null,
});
}Copy the code
The effect of the completed rote component is shown as follows:
Handle special situations gracefully
- Handling user error: On the mobile end, users often accidentally touch the carousel component, which is sometimes triggered when the hand accidentally slides over or clicks
onTouch
Such events. In this regard, we can add a threshold value to the sliding distance to avoid user miscontact. The threshold value can be 10% of the width of the rolling element or other reasonable values. When the sliding distance exceeds the threshold each time, the subsequent sliding of the rolling component will be triggered. - Desktop adaptation: For the desktop, the event name that the multicast component needs to respond to is completely different from that of the mobile terminal, but it can be matched accordingly. Also note here that we need to add a status for the castling component to distinguish mobile from desktop so as to safely reuse the handler part of the code.
// mobile
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
// desktop
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
onMouseUp={this.handleMouseUp}
onMouseLeave={this.handleMouseLeave}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}
handleMouseDown = (evt) => {
evt.preventDefault();
this.setState({
dragging: true}); this.handleTouchStart(evt); } handleMouseMove = (evt) => {if(! this.state.dragging) {return;
}
this.handleTouchMove(evt);
}
handleMouseUp = () => {
if(! this.state.dragging) {return;
}
this.handleTouchEnd();
}
handleMouseLeave = () => {
if(! this.state.dragging) {return;
}
this.handleTouchEnd();
}
handleMouseOver = () => {
if (this.props.autoPlay) {
clearInterval(this.autoPlayTimer);
}
}
handleMouseOut = () => {
if(this.props.autoPlay) { this.autoPlay(); }}Copy the code
summary
At this point we have implemented a tween-functions (tween-functions) component that is dependent on a third party. The package size is 2KB and the full source code can be found in carousel/index.js.
In addition to saving code volume, we were even more pleased to thoroughly understand the implementation mode of the castor component and how to use requestAnimationFrame with setState to complete a set of animations in React.
feeling
Everyone should have seen the above cartoon, interesting but also contains a simple but profound truth, that is, when solving a complex problem, the most important is thinking, but only thinking is still far from enough, but also need specific implementation plan. This specific implementation plan, must be continuous, which can not be missing any link, can not have any ideas or implementation of the jump. So there are no silver bullets or shortcuts to solving any complex problem, we have to figure it out, figure it out, and then we can really solve it.
At this point, the component library design practice series of articles will also come to an end. In all four articles, we have discussed the core topics of component library architecture, component classification, document organization, internationalization and complex component design respectively.
Component libraries are the most important part of improving front-end team efficiency, and you can’t spend too much time studying them. In addition, docking with the design team, forming the design language, docking with the back-end team, unifying the data structure, component library can also be said to be the only way for front-end engineers to expand their work field.
Don’t be afraid to build the wheel over and over again. What matters is what you learn from it each time you build it.
Let me share with you.