From antDesign to spy on the “scroll through” behavior of the mobile terminal

Introduction

I believe that most front-end developers have encountered some unexpected behaviors caused by element scrolling in their daily work.

This article will talk to you about the principles and solutions of unexpected behaviors during scrolling.

Scroll Chaining

?

By default, mobile browsers tend to provide a “bounce” effect or even a page refresh when the top or bottom of a page (or other scroll area) is reached. You may also have noticed that when you have a dialog box with scrolling content on top of a page of scrolling content, once the dialog box’s scroll boundary is reached, the underlying page will then start to scroll – this is called 「scroll chaining」.

?

The above is the description of the overscroll-behavior attribute in MDN, and the above paragraph just describes why the phenomenon of “scroll penetration” occurs.

The simple literal translation is that by default, mobile browsers tend to provide a “bounce” effect or even a page refresh when reaching the top or bottom of the page (or other scrolling area). You may also notice that when the scrolling content page has a dialog with scrolling content at the top, the underlying page starts scrolling once the dialog’s scroll bounds are reached – this is called scrolling “links” .

Phenomena

Intuitively, the so-called Scroll Chaining (scrolling link) is usually accidentally triggered in two situations:

  • “Scrollable background scrolls unexpectedly when dragging a non-scrollable element.”

Usually, when we drag a non-scrollable element, we often accidentally trigger the scrolling of its parent element (background element).

In common business scenarios, such as Dialog, Mask and other content with full screen coverage, when we drag the content of the non-scrollable pop-up layer element, the background element behind it will be accidentally scrolled.

97dea46a33b2c72926b70f7d362dface.gif

file.gif

For example, there are two elements in the picture above, one is a parent element with a red border and a scroll bar, and the other is a child element with a blue border and a black background without a scroll bar.

When we drag a non-scrollable child element, it actually accidentally causes the parent element to follow the scroll.

  • “When dragging a scrollable element to the top or bottom, continuing to drag triggers the scrolling of the nearest scrollable ancestor element.”

There is another common scenario, when we drag on a scrollable element, when the element’s scroll bar has reached the top/bottom. Continue dragging in the same direction, at which point the browser looks for the current element’s nearest scrollable ancestor and accidentally triggers scrolling of the ancestor.

c659ec4267bb9e091e89067cfed3b21c.jpeg

Similarly, the red border in the animation is the parent element that has the scrolling area, and the blue border is the child element in the parent element that also has the scrolling area. When we drag and drop in the sub-element area, when the sub-element scrolls to the bottom (top), we still continue to drag down (up).

Principle

I believe you have encountered the above two situations many times in daily business development. Such unintended scrolling behavior is known in technical terms as “Scroll Chaining”.

So, how did it come about? Or in other words, which constraint of the browser mandates such behavior?

Carefully review the scroll-event on w3c and there is no clear regulation on this.

eee99989ba8fd68ab392b38095a45786.png

image.png

The manual only clarifies that the target of the scrolling event can be Document and the Element inside it. When the target is Document, the event will bubble. And when Target is Element, no bubbling will occur, only fire an event named scroll at target.

In other words, the specification does not clearly stipulate how to implement unexpected behaviors such as scroll chaining.

For example, the manual stipulates the necessary characteristics of scrolling in Element and Document and how to deal with these characteristics at the code level, but the manual does not mandate that certain behaviors cannot be implemented, just like scroll chaining behavior.

Different browser vendors have followed the behavior of scroll chaining in private, and the manual does not mandate that this behavior should not be implemented, and naturally this behavior is not disallowed.

Solution ideas

Through the above description, we have already understood the principle of “scroll penetration”: most browser manufacturers will try to trigger the scrolling of the ancestor node if the target node cannot scroll, such as the first phenomenon above. When the target node can be scrolled, when scrolling to the top/bottom to continue scrolling, the scrolling of the ancestor node will also be accidentally triggered.

On the mobile side, we can use a general solution to solve the above unexpected behavior of “scroll penetration”:

Regardless of whether the element can be scrolled, we only need to judge each time the drag event of the element is triggered:

  1. Find the currently triggered touchMove event event.target “(event.currentTarget) (including) scrollable closest to the event binding element ancestor element.”

The reason for looking for event.target elements to event.currentTarget (inclusive) scrollable ancestor elements” is because we need to determine whether this scrolling is valid.

  • If there is no scrollable element among the ancestor elements within the above range, it means that the entire area is actually not scrollable. Then you don’t need to trigger any unexpected scrolling behavior of the parent element, just do event.preventDefault() to prevent the default.

  1. If there is a scrollable element in the ancestor element within the above range:

    1. First of all, we need the elements in the area to scroll normally.

    2. Second, if the element has already scrolled to the top/bottom, we need to call event.preventDefault() to prevent the unexpected scrolling behavior of the parent element when continuing to scroll in the same direction.

General Hook Scheme

useTouch drag position

First, let’s look at a simple Hook about mobile scrolling:

import { useRef } from 'react'

const MIN_DISTANCE = 10

type Direction = '' | 'vertical' | 'horizontal'

function getDirection(x: number, y: number) {
  if (x > y & & x > MIN_DISTANCE) {
    return 'horizontal'
  }
  if (y > x & & y > MIN_DISTANCE) {
    return 'vertical'
  }
  return ''
}

export function useTouch() {
  const startX = useRef(0)
  const startY = useRef(0)
  const deltaX = useRef(0)
  const deltaY = useRef(0)
  const offsetX = useRef(0)
  const offsetY = useRef(0)
  const direction = useRef<Direction>('')

  const isVertical = () => direction. current === 'vertical'
  const isHorizontal = () => direction. current === 'horizontal'

  const reset = () => {
    deltaX.current = 0
    deltaY.current = 0
    offsetX.current = 0
    offsetY.current = 0
    direction.current = ''
  }

  const start = ((event: TouchEvent) => {
    reset()
    startX.current = event.touches[0].clientX
    startY.current = event.touches[0].clientY
  }) as EventListener

  const move = ((event: TouchEvent) => {
    const touch = event. touches[0]
    // Fix: Safari back will set clientX to negative number
    deltaX.current = touch.clientX < 0 ? 0 : touch.clientX - startX.current
    deltaY.current = touch.clientY - startY.current
    offsetX.current = Math.abs(deltaX.current)
    offsetY.current = Math.abs(deltaY.current)

    if (!direction. current) {
      direction.current = getDirection(offsetX.current, offsetY.current)
    }
  }) as EventListener

  return {
    move,
    start,
    reset,
    startX,
    startY,
    deltaX,
    deltaY,
    offsetX,
    offsetY,
    direction,
    isVertical,
    isHorizontal,
  }
}

I believe you can see the above code at a glance, useTouch this hook defines three start, move, code>reset method.

  • The start method accepts the TouchEvent object, and calls reset to clear delta, offset and direction value. At the same time, record the distance clientX and clientY values from the viewport when the event object occurs as the initial value.

  • The move method also accepts the TouchEvent object as an input parameter, and is calculated according to the position attribute on the TouchEvent:

    • deltaX and deltaY are two values, which represent the distance from the initial value when moving, and can be negative in different directions.

    • offsetX, offsetY represent the absolute distance in the X direction and Y direction when moving compared to the initial value.

    • direction calculates the moving direction by comparing offsetX and offsetY.

  • The reset method is to perform a unified clear and remake of the variables mentioned above.

Through the hook of useTouch, we can easily calculate the movement of the finger when dragging with touchstart and onTouchMove on the mobile terminal. direction and distance.

getScrollParent Find scrollable ancestor elements in the area

// The canUseDom method is to judge whether Dom can be used, mainly for screening ( Server Side Render )
import { canUseDom } from './can-use-dom'

type ScrollElement = HTMLElement | Window

const defaultRoot = canUseDom? window : undefined

const overflowStylePatterns = ['scroll', 'auto', 'overlay']

function isElement(node: Element) {
  const ELEMENT_NODE_TYPE = 1
  return node.nodeType === ELEMENT_NODE_TYPE
}
export function getScrollParent(
  el: Element,
  root: ScrollElement | null | undefined = defaultRoot
): Window | Element | null | undefined {
  let node = el

  while (node & amp; & amp; node !== root & amp; & amp; isElement(node)) {
    if (node === document. body) {
      return root
    }
    const { overflowY } = window. getComputedStyle(node)
    if (
      overflowStylePatterns. includes(overflowY) & amp; & amp;
      node.scrollHeight > node.clientHeight
    ) {
      return node
    }
    node = node. parentNode as Element
  }
  return root
}

The getScrollParent method essentially goes from el(event.target) to root(event.currentTarget code>) to find the nearest scroll ancestor element.

The code is also not particularly difficult to understand. In the while loop, look up layer by layer from the first parameter el passed in. Either find a scrollable element, or keep searching until node === root and return directly to root.

For example, such a scene:

import { useEffect, useRef } from 'react';
import './App.css';
import { getScrollParent } from './hooks/getScrollParent';

function App() {
  const ref = useRef<HTMLDivElement>(null);

  const onTouchMove = (event: TouchEvent) => {
    const el = getScrollParent(event. target as Element, ref. current);
    console.log(el, 'el'); // child-1
  };

  useEffect(() => {
    document.addEventListener('touchmove', onTouchMove);
  }, []);

  return (
    <>
      <div ref={ref} className="parent">
        <div
          className="child-1"
          style={<!-- -->{
            height: '300px',
            overflowY: 'auto',
          }}
        >
          <div
            style={<!-- -->{
              height: '600px',
            }}
          >
            This is child-2
          </div>
        </div>
      </div>
    </>
  );
}

export default App;

When we drag and scroll the content of This is child-2 on the page, the console will print getScrollParent from event.target (that is, This is child-2 element) finds the nearest scrolling element .child-1 element in the area named .parent.

useScrollLock general solution

Above we learned about a basic useTouch hook for drag position calculation and getScrollParent method to obtain the nearest scrollable ancestor element in the area. Next, let’s take a look at the general hook that prevents scroll chaining unexpected scrolling behavior in the mobile terminal.

Here, I directly paste a piece of implementation code in ant-design-mobile, (actually this is from vant in ant-design-mobile ported code):

import { useTouch } from './use-touch'
import { useEffect, RefObject } from 'react'
import { getScrollParent } from './get-scroll-parent'
import { supports Passive } from './supports-passive'

let totalLockCount = 0

const BODY_LOCK_CLASS = 'adm-overflow-hidden'

function getScrollableElement(el: HTMLElement | null) {
  let current = el?.parentElement

  while (current) {
    if (current. clientHeight < current. scrollHeight) {
      return current
    }

    current = current. parentElement
  }

  return null
}

export function useLockScroll(
  rootRef: RefObject<HTMLElement>,
  shouldLock: boolean | 'strict'
) {
  const touch = useTouch()

  /**
   * when finger dragging
   * @param event
   * @returns
   */
  const onTouchMove = (event: TouchEvent) => {
    touch. move(event)

    // Get the drag direction
    // If deltaY is greater than 0, the current Y-axis position of dragging is greater than the starting position, that is, dragging from bottom to top will change the direction to '10', otherwise it will be `01`
    const direction = touch.deltaY.current > 0 ? '10' : '01'

    // As we mentioned above, find scrollable elements within the range
    const el = getScrollParent(
      event. target as Element,
      rootRef.current
    ) as HTMLElement
    if (!el) return

    // This has perf cost but we have to compatible with iOS 12
    if (shouldLock === 'strict') {
      const scrollableParent = getScrollableElement(event. target as HTMLElement)
      if (
        scrollableParent === document. body ||
        scrollableParent === document.documentElement
      ) {
        event. preventDefault()
        return
      }
    }

    // Get the position property of the scrollable element
    const { scrollHeight, clientHeight, offsetHeight, scrollTop } = el

    // define initial status
    let status = '11'

    if (scrollTop === 0) {
      // The scroll bar is at the top, indicating that it has not been scrolled yet
      // When the scroll bar is at the top, it is necessary to judge whether the current element cannot be scrolled or can be scrolled but no scrolling has been performed

      // When offsetHeight >= scrollHeight means that the current element is not scrollable, then change the status to 00,
      // Otherwise, it means that the current element is scrollable but the scroll bar is at the top, change the status to 01
      status = offsetHeight >= scrollHeight ? '00' : '01'
    } else if (Math. abs(scrollHeight - clientHeight - scrollTop) < 1) {
      // The scroll bar has reached the bottom (indicating that it has been scrolled to the end), change the status to '10'
      status = '10'
    }

    // 1. After completing the above judgment, if status === 11 means that the current element is scrollable and the scroll bar is neither at the top nor at the bottom (that is, in the middle), it means that the touchMove event should not prevent the element from scrolling (current scrolling is normal)

    // 2. At the same time, touch.isVertical() explicitly ensures that it is a vertical drag

    // 3. parseInt(status, 2), when the status is not 11, it is divided into the following three situations:
    
      // 3.1 status 00 means no scrollable elements were found in the area
      // 3.2 status 01 means that a scrollable element is found, and the current element is the scroll bar at the top
      // 3.3 status 10 indicates that a scrollable element has been found, and the scroll bar of the current element is at the bottom
    // Naturally parseInt(status, 2) & amp; parseInt(direction, 2) The binary method is used here,

      // 3.4 When the status is 00, 0 & amp; any number is 0. Naturally! (parseInt(status, 2) & amp; parseInt(direction, 2)) will become true (corresponding to 3.1), which needs to be blocked Unexpected scrolling behavior.

      // 3.5 When the status is 01 (corresponding to 3.2 the scroll bar is at the top), when the user drags from bottom to top, it is necessary to prevent unexpected scrolling behavior from happening. Otherwise, there is no need to prevent normal scrolling. Natural status === '01', direction === 10 (drag from bottom to top), !(parseInt(status, 2) & amp; parseInt(direction, 2)) is true to prevent default scrolling Behavior. (1 & amp; 1 is 1 in base system, 1 & amp; 2 is 0)

      // 3.6 According to the situation in 3.5, when the status is 10 (corresponding to 3.3) scrolling reaches the bottom, it should also be blocked when the direction is 01 when dragging from top to bottom, so (2 & amp;1 = 0) naturally!( parseInt(status, 2) & amp; parseInt(direction, 2)) is true, and will also enter the if statement to prevent accidental scrolling.
      
    if (
      status !== '11' & amp; & amp;
      touch.isVertical() & amp; & amp;
      !(parseInt(status, 2) & parseInt(direction, 2))
    ) {
      if (event. cancelable) {
        event. preventDefault()
      }
    }
  }

  /**
   * Lock method
   * 1. Add touchstart and touchmove event listeners
   * 2. According to totalLockCount, add overflow hidden style class name to body when hook runs
   */
  const lock = () => {
    document.addEventListener('touchstart', touch.start)
    document. addEventListener(
      'touchmove',
      onTouchMove,
      supportsPassive ? { passive: false } : false
    )

    if (!totalLockCount) {
      document.body.classList.add(BODY_LOCK_CLASS)
    }

    totalLockCount++
  }

  /**
   * Remove the event listener method when the component is destroyed, and clear the overflow hidden class name on the body
   */
  const unlock = () => {
    if (totalLockCount) {
      document.removeEventListener('touchstart', touch.start)
      document.removeEventListener('touchmove', onTouchMove)

      totalLockCount--

      if (!totalLockCount) {
        document.body.classList.remove(BODY_LOCK_CLASS)
      }
    }
  }

  useEffect(() => {
    // If shouldLock is passed in, it means that accidental scrolling needs to be prevented
    if (shouldLock) {
      lock()
      return () => {
        unlock()
      }
    }
  }, [shouldLock])
}

I have made detailed comments on each line in the above code snippet. If you read this code carefully, I believe it is not difficult for everyone to understand. The above code still solves the unexpected behavior of scrolling links on the mobile terminal according to the solution we described at the beginning of the article.

There are a few small Tips in the above code, here I will repeat them a little bit with you:

  1. About shouldLock === 'strict' In this case, the antd source code indicates that it is compatible with IOS12 clearing. If this code confuses your thinking, you can completely ignore it It, because it’s not what we primarily want to dwell on.

  2. addEventListener The third parameter { passive: false } is true by default in browsers other than safari, which will cause preventDefault() in some event functions code> is invalid, the so-called passive appeared after the chrome51 version, essentially to improve scrolling performance through passive listeners. For details, you can check the explanation of MDN, so I won’t go into details here.

  3. The actual style of BODY_LOCK_CLASS is actually overflow:hidden, the reason why it is added by counting totalLockCount is nothing special. Imagine if every Modal pop-up window in your page uses the useLockScroll hook, then when two pop-up windows are opened on the page, when one is closed and the other still exists, it cannot be removed Set BODY_LOCK_CLASS.

  4. Adding overflow:hidden to body actually doesn’t have much practical effect on the mobile side. The processing logic in our touchmove event is useful for preventing accidental scrolling It is perfectly enough that the behavior occurs. I didn’t quite understand why I did this at first, so I also went to vant for advice, see vant Discussions for details.

    In fact, the source code does not use Math.abs(scrollHeight - clientHeight - scrollTop) < 1 to judge whether the scroll bar reaches the bottom, but uses scrollTop + offsetHeight >= scrollHeight obviously This is inaccurate and may cause bugs (since scrollTop is a non-rounded number (can be a decimal), while scrollHeight and clientHeight are Rounded numbers) So extreme scenarios will lead to inaccuracy, I have encountered it, friends who are interested in knowing more, please refer to my PR for antd-mobile.

Conclusion

The article is here to say goodbye to everyone. I just encountered this problem when writing mobile components in the company some time ago, so I took it out to share with you.

Of course, if you have any doubts about the content of the article or have a better solution. You can leave your opinion in the comment area, we can discuss together, thank you.

9077355a918c2a6d2d3442dd82c51d20.gif