LoginSignup
5

More than 3 years have passed since last update.

posted at

updated at

Organization

Swipe Item Opener

モバイルデバイスならではの UI の代表的なものに、スワイプを利用したものがあります。本日から4日間は、このスワイプを作ったサンプルを紹介します。初日は、メールアプリの様な、スワイプしてアイコンを表示・アイコンタップで既読フラグをたてる実装です。
code: github / $ yarn 1211

1211.jpg 1211-1.jpg

Component 概要図

TouchDown / TouchUp 時に、CSS の transitionDuration を切り替えるのがコツです。要素をスワイプしている間は、0sでタッチ座標に追従、離した瞬間に CSS アニメーションを利用し固定座標へ移動します。

1211.png

この概要図の通りに実装を見ていきます。

render props + useMemo

タッチ座標の伝播を必要最小限の Component に留めるため、今回も render props を利用します。既読フラグを render props 引数で受け取り、既読フラグ変化時のみ rerender します。

/components/item/index.tsx
const View = (props: Props) => (
  <SwipeItemOpener
    render={opend => (
      <div className={props.className}>
        <Head
          title={props.title}
          dateLabel={props.dateLabel}
          opend={opend}
        />
        <div className="body">{props.body}</div>
      </div>
    )}
  />
)

SwipeItemOpener コンポーネントは以下の様になっています。containerStyle の変化が頻繁に起こります。

components/item/swipeItemOpener.tsx
const View = (props: Props) => {
  const {
    opend,
    containerStyle,
    handleTouchDown,
    handleTouchUp,
    handleTouchMove
  } = useSwipeItemOpener()
  return (
    <div
      className={props.className}
      onTouchStart={handleTouchDown}
      onTouchEnd={handleTouchUp}
      onTouchCancel={handleTouchUp}
      onTouchMove={handleTouchMove}
    >
      {useMemo(
        () => (
          <div className="icon">
            <SVGInline
              svg={require('./assets/email.svg')}
            />
          </div>
        ),
        [] // 変化無しのため rerender ブロック
      )}
      <div className="container" style={containerStyle}>
        {useMemo(() => props.render(opend), [opend])}
        // opened 変化時のみ rerender
      </div>
    </div>
  )
}

useSwipeItemOpener

今回定義している Custom Hooks 内訳です。

components/item/useSwipeItemOpener.ts
function useSwipeItemOpener(props?: Props) {
  const [state, update] = useState<State>(...)
  const options = useMemo(...)
  const handleTouchDown = useCallback(...)
  const handleTouchUp = useCallback(...)
  const handleTouchMove = useCallback(...)
  const containerStyle = useMemo(...)
  return {
    opend: state.opend,
    containerStyle,
    handleTouchDown,
    handleTouchUp,
    handleTouchMove
  }
}

触れた瞬間の処理

指の移動量を保持するために、初期タップ時座標を保持します。この時、触られているフラグをオンに、transitionDurtion を 0s にします。

components/item/useSwipeItemOpener.ts
const handleTouchDown = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    update(_state => {
      if (_state.opend) return _state
      const startX = event.touches[0].clientX
      const opend =
        _state.showOpener && startX < options.threshold
      return {
        ..._state,
        isTouchDown: true,
        startX,
        showOpener: false,
        transitionDuration: 0,
        opend
      }
    })
  },
  []
)

指を動かしている時の処理

初期座標と現在座標の差分を、移動量として保持します。

components/item/useSwipeItemOpener.ts
const handleTouchMove = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    update(_state => {
      if (_state.opend) return _state
      const offsetX =
        event.touches[0].clientX - _state.startX
      if (offsetX < 0) return _state
      return {
        ..._state,
        isTouchDown: false,
        showOpener: offsetX > options.threshold,
        offsetX
      }
    })
  },
  []
)

離した瞬間の処理

移動量が閾値を超えていた場合、アイコンが見えきる位置に座標を固定。超えていなかった場合、元の座標に戻します。指定の transitionDuration を与えることでアニメーションします。

components/item/useSwipeItemOpener.ts
const handleTouchUp = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    update(_state => ({
      ..._state,
      isTouchDown: false,
      offsetX: _state.showOpener ? options.threshold : 0,
      startX: 0,
      transitionDuration: 200
    }))
  },
  []
)

開いた状態から閉じる

冒頭の handleTouchDown に戻ります。開いている状態でアイコン以外がタップされた場合、既読処理キャンセルと判定します。アイコンがタップされた場合は、既読フラグをたてます。より実用的にする場合、アイコンタップした後に APIを叩き、レスポンスに応じて閉じると良いでしょう。

components/item/useSwipeItemOpener.ts
const handleTouchDown = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    update(_state => {
      if (_state.opend) return _state
      const startX = event.touches[0].clientX
      const opend =
        _state.showOpener && startX < options.threshold
      return {
        ..._state,
        isTouchDown: true,
        startX,
        showOpener: false,
        transitionDuration: 0,
        opend
      }
    })
  },
  []
)

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
5