This article was originally published at github.com/bigo-fronte… Welcome attention, reprint.

Demand analysis:

  • You need to provide a component that automatically determines whether or not to scroll based on the width of the child element.
  • When rolling, there is an end to end effect, that is, when rolling to the end of the line, the head of the line appears from the other end at the same time (that is, loop, open constantly in the middle)

  • As you scroll, you can drag elements by touching them (either left or right)
  • Consider multilingual reading directions, such as Chinese reading from left to right, while marquee scrolling from right to left, Arabic reading from right to left, and marquess scrolling from left to right
  • Use VUE development

implementation

1. Scroll according to the width of the child element

  • The component structure consists of a top-level div as a container, a secondary div as a scroller, and a slot within the scroller to receive the scroll element
  • Considering that the component is to scroll horizontally, set the div of the internal scroll region toflexLayout so that incoming elements are arranged horizontally
  • The specific page structure is as follows:
<div class="horizontal-scroller" ref="myContainer">
    <div class="__scroller-panel" ref="myScroller">
        <slot></slot>
    </div>
</div>

/* style */

.horizontal-scroller {
  overflow: hidden;
  width: auto;
  max-width: 600px;
  display: inline-block;
  white-space: nowrap;
  position: relative;
  .__scroller-panel {
    white-space: nowrap;
    width: auto;
    display: inline-flex;
  }
}

Copy the code
  • Notice that both containner and scroller have width set toautoBut containner has a maximum width beyond which it hides its inner elements. In addition, both Containner and Scroller have set the inner elements not to break lineswhite-space: nowrapTo ensure that all the elements are arranged in one row
  • Finally, the scroller sets the inline-Flex layout. Note that the setting isinline-flexIf set to Flex, the maximum width of the scroller will be equal to the width of its parent element, so that the scroller cannot be held apart by the inner element and thus cannot get the full width of the inner element
  • And then by reading the element’sclientWidthAnd the containerclientWidthTo determine whether scrolling is currently allowed
  • Because of the use ofvue slotThe form that needs to be placed in the judgment logicnexttickTo ensure that the judgment is made when the element has been rendered, the initialization judgment can be placed inmountedIn, the code is as follows:
mounted() { this.refresh(); } refresh() { this.resetTimmer(); $nextTick() => {if (! this.$refs.myScroller) return; this.currentTranslateX = 0; This.containerwidth = 0; This.scrollerwidth = 0; this.scrollerWidth = 0; This. marquee = false; Const {clientWidth: scrollerWidth} = this.$refs.myscroller; const { clientWidth: containerWidth } = this.$refs.myContainer; If (scrollerWidth > containerWidth + 2) {this.marquee = true; this.containerWidth = containerWidth; this.scrollerWidth = scrollerWidth; this.currentTranslateX = 0; This.processmarquee (); }}); }Copy the code
  • In Mounted, you first clear the timer, which is used to make the element scroll. This logic will be mentioned later, but you need to know what it does
  • After the timer is cleared, the variables are initialized and the width of the scroll area is read, as well as the width of the container. On some models, the element width is a little wider than the container width even before the max-wdith, so there is a bit of compatibility (+2 in the condition).
  • Set marquee to true if the width of the element is smaller than the width of the element, indicating that the element can be scrolled, and record the current element width as well as the container width.
processMarquee() { if (! this.marquee) return; this.timmer && clearTimeout(this.timmer); This.timmer = setTimeout(() => {if (! this.isNeedReset(1, true)) { this.currentTranslateX += 1; } this.processMarquee(); }, 20); },Copy the code
  • First, the following logic is executed only if it is determined to scroll, and there is an action to clear the timer to ensure that it is executed only once
  • Then, by settimout, move the element by adding one to currentTranslateX (the current element movement)
  • IsNeedReset here, is used to determine whether need to move the current element (the element to the queue to the head or the tail), so the vision would look from left to right loop wheel, the logic of this part will discuss later, here just need to know, if you don’t need to reset the position, is to add a displacement, if you need to reset the position, In isNeedReset, the reset position will be performed
  • Next, look at currentTranslateX. This value is used to calculate the offset of the element, which is then used as the style of the element to notify the browser to move the element.
<div class="horizontal-scroller" ref="myContainer"> <div class="__scroller-panel" ref="myScroller" :style="positionStyle"> <slot></slot> </div> </div> /* script */ computed: { positionStyle() { return { transform: `translateX(${-this.currentTranslateX}px)` }; }},Copy the code

Loop by

  • Currently, there is only one Scroller element in the container, and we must wait for the element to completely disappear from the head or tail of the container before we can use isNeedReset to reset the element to achieve the effect of rotation
  • To make the head of an element appear immediately after the end of the element before it completely disappears, we need at least two Scroller elements and one as the Clone element of the first element
  • Here we call the first element the original element and the copied element the Clone element
  • There are many ways to copy the Clone element, the most common one is throughnode.cloneNodeBut in VUE, elements copied this way cannot respond to clicks and other events bound to the original element, so in VUE, multipleslotThere are multiple copy features to copy elements, as follows:
<div class="horizontal-scroller" ref="myContainer"> <div class="__scroller-panel" ref="myScroller" :style="positionStyle"> <slot></slot> </div> <div class="__scroller-panel-clone" ref="myScrollerClone" :style="positionStyleClone"> <slot></slot> </div> </div> /* style */ .horizontal-scroller { overflow: hidden; width: auto; max-width: 600px; display: inline-block; white-space: nowrap; position: relative; .__scroller-panel. __scroller-panel-Clone {// add white-space: nowrap; width: auto; display: inline-flex; }.__scroller-panel-clone {// Add position: Absolute; top: 0; left: 0; }} /* script */ computed: {// new positionStyleClone() {return {transform: `translateX(${-this.currentTranslateXClone}px)` }; }},Copy the code
  • The Clone element is basically the same style as the original element, but the Clone element is positioned absolutely, so that the container is not stretched extra wide by the Clone element
  • At present, the Clone element and the original element are completely overlapped. We need to remove the Clone element and arrange it for display. Therefore, add the following logic in the initialization phase
refresh() { this.resetTimmer(); this.$nextTick(() => { if (! this.$refs.myScroller) return; this.currentTranslateX = 0; this.containerWidth = 0; this.scrollerWidth = 0; this.paddingStyle = {}; // Add, initialize the padding style this.iscloneBeofre = false; }}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}} This. $nextTick() => {const {clientWidth: scrollerWidth } = this.$refs.myScroller; const { clientWidth: containerWidth } = this.$refs.myContainer; If (scrollerWidth > containerWidth + 2) {const paddingWidth = containerWidth * 0.1; This. PaddingStyle = {paddingWidth: '0 ${paddingWidth}px'}; this.marquee = true; this.containerWidth = containerWidth; this.scrollerWidth = scrollerWidth + 2 * paddingWidth; CurrentTranslateX = paddingWidth-2; / / modify this. ProcessMarquee (); }}); }); }Copy the code
  • A new isCloneBefore flag is used to calculate the offset of the Clone element, indicating whether the clone element is in front of the original element. In this case, the displacement of the Clone element is completely dependent on the original element. The logic will be explained later
  • In addition, given that the original element and the Clone element need to be separated slightly in the round to better distinguish a set of data, we use a padding here
  • Because of the extra padding, you need to reset the padding before you can get the real element width, and wait for a nexttick after the reset to get the actual element width
  • Applying the padding style:
<div class="horizontal-scroller" ref="myContainer"> <div class="__scroller-panel" ref="myScroller" :style="{ ... paddingStyle, ... positionStyle }" > <slot></slot> </div> <div class="__scroller-panel-clone" ref="myScrollerClone" :style="{ ... paddingStyle, ... positionStyleClone }" > <slot></slot> </div> </div>Copy the code
  • Back to the Clone element, before calculating its offset, we first analyze the correspondence between the Clone element and the original element. Since the beginning and end of the element cannot be in the visible area of the container at the same time (the element should be wider than the container), the location of the Clone element should be considered at the same time, so the following four scenarios are summarized for the location of the element: A. If the head of the original element is visible, the end of the Clone element is also visible, and the Clone element is before the original element B. If the end of the original element is visible, then the head of the Clone element is also visible, and the Clone element is following the original element C. D. Only the clone element is visible
  • When the element to scroll from right to left, position change the order of C – > B > D – > A, due to the four elements in C and D status, the original sequence of elements and clone element which is random, does not affect the display, then we can assume that C state, clone elements behind the original elements, D status, The Clone element precedes the original element and is summarized as follows: A. If the head of the original element is visible, the end of the Clone element is also visible, and the Clone element is before the original element B. If the end of the original element is visible, then the head of the Clone element is also visible, and the Clone element is following the original element C. D. Only the clone element is visible. The clone element is before the original element

  • The advantage of such treatment is that the change of the positions of C to B and D to A does not need to change the position relationship between the Clone element and the original element, so the critical point for the change of position is:
    • B to D, or D to B
    • A to C, or C to A
  • From this we can write the previously mentioned isNeedReset method:
isNeedReset(step, onMarquee = false) { const newCurrentX = onMarquee ? this.currentTranslateX + step : this.startTranslateX + step; If (this.currentatex < 0 && newCurrentX >= 0) {if (this.currentatex < 0 && newCurrentX >= 0) {if (this.currentatex < 0 && newCurrentX >= 0) { this.currentTranslateX = newCurrentX; this.isCloneBeofre = false; return true; } if (this.currentatex > 0 && newCurrentX <= 0) {// If (this.currentatex > 0 && newCurrentX <= 0) {// If (this.currentatex > 0 && newCurrentX <= 0); this.currentTranslateX = newCurrentX; this.isCloneBeofre = true; return true; } if (this.currentatex < this.scrollerWidth && newCurrentX >= this.scrollerWidth) { Console. log(3); this.currentTranslateX = newCurrentX - this.scrollerWidth - this.scrollerWidth; this.isCloneBeofre = true; return true; } if (this.currentatex > -this.scrollerwidth && newCurrentX <= -this.scrollerWidth) { Console. log(4); this.currentTranslateX = this.scrollerWidth - (-this.scrollerWidth - newCurrentX); this.isCloneBeofre = false; return true; } return false; }Copy the code
  • OnMarquee is used to distinguish whether the current state is manually dragged or automatically rolled. Assuming that onMarquee is true, then four critical states can be distinguished by comparing currentTranslateX before and after the change, and the corresponding isCloneBeofre flag can be set
  • When the state changes from B to D and D to B, because the original element needs to be moved, new currentTranslateX needs to be recalculated, and the moving distance is the width of two elements (i.e. ± scrollerWidth x 2).
  • Now that we know the relation between the Clone element and the original element, we can calculate the displacement of the Clone element (currentTranslateXClone). The displacement deviation of the Clone element and the original element is one element width (± scrollerWidth).
computed: { currentTranslateXClone() { if (this.isCloneBeofre) { return this.currentTranslateX + this.scrollerWidth; } return this.currentTranslateX - this.scrollerWidth; }}Copy the code
  • At this point, we have a runaround that can be rotated, and now we need to think about the logic of drag and drop

Touch and drag

  • The triggered area is the entire container, so the listener is placed on the container
<div class="horizontal-scroller" ref="myContainer" @touchstart="onStart" @touchend="onEnd" @touchmove.prevent="onMove" @touchcancel="onEnd" @mousedown="onStart" @mousemove.prevent="onMove" @mouseup="onEnd" @mousecancel="onEnd" @mouseleave="onEnd" > <! - omitted -! > </div>Copy the code
  • When you touch at the same time, stop scrolling
onStart(e) { if (! this.marquee) return; // Drag invalid const point = e.touches? e.touches[0] : e; this.startX = point.pageX; StartTranslateX = this.currentTranslateX; This. stop = true; // This method is used to stop the marquee (); } stopMarquee() { clearTimeout(this.timmer); this.timmer = null; }Copy the code
  • Since dragging is a continuous process and can drag back and forth, the position of the click and the initial displacement are required. In the onMove method, we combine these two variables and the current touch point position to calculate the displacement:
onMove(e) { if (this.gestureTimmer || ! this.marquee) return; const point = e.touches ? e.touches[0] : e; const diffX = Math.round(this.startX - point.pageX); // If (! this.isNeedReset(diffX)) { this.currentTranslateX = this.startTranslateX + diffX; } else { this.startTranslateX = this.currentTranslateX; this.startX = point.pageX; } this.gestureTimmer = setTimeout(() => { this.gestureTimmer = null; }, 20); }Copy the code
  • During the drag process, the gestureTimmer is added and can only be triggered once every 20ms
  • Calculate the displacement between start and move, plus startTranslateX recorded when start, is the displacement value that needs to be updated
  • If onMarquee is false, the next displacement value is also calculated as shown above. However, unlike automatic scrolling, when the reset position is determined, the current startTranslateX and startX need to be reset. This is because the displacement changes after the reset position, and the two values need to be refreshed according to the current touch point coordinates, so that the subsequent calculation can be accurate
  • Restart the scroll at the end of the last touch
onEnd() { if (! this.marquee) return; this.stop = false; this.processMarquee(); }Copy the code
  • Note that these methods are executed only if marquee is true, which means that the element’s width is larger than the container’s in the first place. In other words, dragging is invalid if the container’s width is larger than the element’s width
  • The last thing to add to the initialization method is the resetTimmer, which is also very simple, to clear the scroll timer and drag timer mentioned above
resetTimmer() {
    clearTimeout(this.timmer);
    clearTimeout(this.gestureTimmer);
    this.timmer = null;
    this.gestureTimmer = null;
}
Copy the code

Consider the multilingual case

  • The above mainly realizes the running lamp that meets the habit of reading from left to right, and for the language that reads from right to left, it also needs to meet that the elements are arranged from right to left, and the running lamp moves automatically from left to right
  • Since the container is laid out in Flex style here, you only need to add it in the bodydirection: rtlThe elements in the container can be arranged from right to left, and because the Clone element is absolutely positioned, it needs to be positioned in languages that are used to reading from right to leftleft: 0Instead ofright: 0In order to make the Clone element and the original element overlap in the initial state
body { direction: rtl; }.__scroller-panel-clone {// Add position: Absolute; top: 0; right: 0; }Copy the code
  • The next thing that needs to change is the direction of motion, the variablepositionStylewithpositionStyleCloneLet’s add a variablerightWhen right is true, we need to reverse the direction of the element movement, i.epositionStylewithpositionStyleCloneIn thetranslateIn the opposite direction, as follows:
Computed: {// New positionStyle() {const translateX = this.right? this.currentTranslateX : -this.currentTranslateX; return { transform: `translateX(${translateX}px)` }; }, positionStyleClone() { const translateX = this.right ? this.currentTranslateXClone : -this.currentTranslateXClone; return { transform: `translateX(${translateX}px)` }; }},Copy the code
  • After changing the direction of motion, the motion of the autoscroll portion is complete, and as currentTranslateX increases, the element will now move from left to right
  • But it turns out that when the user drags from left to right, the element moves from right to left, because the displacement of the drag is calculated by the displacement of the coordinates of the touch points when touching the screen, and the displacement of the automatic scroll1The displacement of the drag is the same in the left-to-right and right-to-left contexts. It does not change because of the context1In different contexts, it represents different directions, such as moving one px to the right in the right-to-left context
  • So the last thing we need to change is the way we calculate the displacement of the touch drag part, and the way we can change it is by taking the negative value of it, because in different contextspositionStyleIn the opposite way, from left to right,currentTranslateXThe addition of a will cause the element to move to the left, whereas in a right-to-left context,currentTranslateXThe increment of causes the element to move to the right, so if you want the element to move in the same direction as the touch drag, calculate the coordinate changediffXYou have to take the opposite value, which is
onMove(e) { if (this.gestureTimmer || ! this.marquee) return; const point = e.touches ? e.touches[0] : e; let diffX = Math.round(this.startX - point.pageX); if (this.right) diffX = -diffX; if (! this.isNeedReset(diffX)) { this.currentTranslateX = this.startTranslateX + diffX; } else { this.startTranslateX = this.currentTranslateX; this.startX = point.pageX; } this.gestureTimmer = setTimeout(() => { this.gestureTimmer = null; }, 20); }Copy the code
  • In this way, you can have a loop that meets different reading habits, and it also supports drag and drop effects

conclusion

There are plenty of more powerful alternatives out there, but implementing such a feature on your own can help you better understand the internal implementation logic of other tools. For such a problem, often need to separate the complicated problem into a simple single small problems, such as analysis of the location of the relationship between the original elements and clone elements, to the scene of a complex can be divided into four specific critical point, and the more language implementation, specific implementation is divided into the wheel, and drag and drop two scenarios, This way of thinking is really what needs to be mastered.

Welcome to leave a message and discuss, I wish a smooth work, happy life!

I’m bigo front end, see you next time.