Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(react-sandbox): 手動スクロールに関連する挙動修正 #376

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 9 additions & 29 deletions packages/react-sandbox/src/components/Carousel/index.story.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { boolean, number, select, withKnobs } from '@storybook/addon-knobs'
import { number } from '@storybook/addon-knobs'
import styled, { css } from 'styled-components'
import Carousel from '.'
import Carousel, { CarouselProps } from '.'

const dummyText = css`
color: ${({ theme }) => theme.color.text4};
Expand All @@ -19,43 +19,20 @@ const Dummy = styled.div`
`
export default {
title: 'Sandbox/Carousel',
decorators: [withKnobs],
component: Carousel,
}

export const _Carousel = () => {
const hasGradient = boolean('Gradient', false)
const fadeInGradient = boolean('FadeInGradient', false)
const buttonOffset = number('buttonOffset', 0)
const buttonPadding = number('buttonPadding', 16)
const defaultScrollAlign = select(
'scrollAlign',
{
Left: 'left',
Center: 'center',
Right: 'right',
},
'left'
)
const defaultScrollOffset = number('scrollOffset', 0)
const DefaultStory = (args: CarouselProps) => {
const itemCount = number('Item count', 20)
const itemSize = number('Item size', 118)

const items = Array.from({ length: itemCount })
return (
<Base>
<Carousel
buttonOffset={buttonOffset}
buttonPadding={buttonPadding}
defaultScroll={{
align: defaultScrollAlign,
offset: defaultScrollOffset,
}}
hasGradient={hasGradient}
fadeInGradient={fadeInGradient}
>
<Carousel {...args}>
<Container>
{items.map((_value, index) => (
<Box size={itemSize} key={index}>
<Box size={itemSize} key={index} tabIndex={0}>
Dummy
</Box>
))}
Expand All @@ -64,6 +41,9 @@ export const _Carousel = () => {
</Base>
)
}

export const Default = DefaultStory.bind({})

const Base = styled.div`
width: 100%;
padding: 0 108px;
Expand Down
110 changes: 57 additions & 53 deletions packages/react-sandbox/src/components/Carousel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as React from 'react'
import { animated, useSpring } from 'react-spring'
import styled, { css } from 'styled-components'
import { useDebounceAnimationState } from '../../foundation/hooks'
import { passiveEvents, isEdge } from '../../foundation/support'
import { isEdge } from '../../foundation/support'
import { useIsomorphicLayoutEffect } from '../../hooks'
import CarouselButton, { Direction } from '../CarouselButton'

Expand Down Expand Up @@ -38,7 +38,7 @@ export type CarouselGradientProps =
type CarouselAppearanceProps = CarouselBaseAppearanceProps &
CarouselGradientProps

type Props = CarouselAppearanceProps & {
export type CarouselProps = CarouselAppearanceProps & {
onScroll?: (left: number) => void
onResize?: (width: number) => void
children: React.ReactNode
Expand All @@ -63,22 +63,27 @@ export default function Carousel({
onScrollStateChange,
scrollAmountCoef = SCROLL_AMOUNT_COEF,
...options
}: Props) {
}: CarouselProps) {
// スクロール位置を保存する
// アニメーション中の場合は、アニメーション終了時のスクロール位置が保存される
const [scrollLeft, setScrollLeft] = useDebounceAnimationState(0)
// アニメーション中かどうか
const animation = useRef(false)

// アニメーション中の場合はアニメーション終了時のスクロール位置を保持する
// それ以外の時は undefined
const [animationTarget, setAnimationTarget] = useState<number | undefined>(
undefined
)

// スクロール可能な領域を保存する
const [maxScrollLeft, setMaxScrollLeft] = useState(0)

// 左右のボタンの表示状態を保存する
const [leftShow, setLeftShow] = useState(false)
const [rightShow, setRightShow] = useState(false)

// const [props, set, stop] = useSpring(() => ({
// scroll: 0
// }))
const [styles, set] = useSpring(() => ({ scroll: 0 }))
const [styles, set] = useSpring(() => ({
scroll: 0,
onRest: () => setAnimationTarget(undefined),
}))

const ref = useRef<HTMLDivElement>(null)
const visibleAreaRef = useRef<HTMLDivElement>(null)
Expand All @@ -89,61 +94,59 @@ export default function Carousel({
return
}
const { clientWidth } = visibleAreaRef.current
// アニメーション中は現在の終了地点から次の終了地点を計算する
const from = animationTarget ?? scrollLeft
// スクロール領域を超えないように、アニメーションを開始
// アニメーション中にアニメーションが開始されたときに、アニメーション終了予定の位置から再度計算するようにする
const scroll = Math.min(
scrollLeft + clientWidth * scrollAmountCoef,
from + clientWidth * scrollAmountCoef,
maxScrollLeft
)
setScrollLeft(scroll, true)
set({ scroll, from: { scroll: scrollLeft }, reset: !animation.current })
animation.current = true
}, [
animation,
maxScrollLeft,
scrollLeft,
set,
scrollAmountCoef,
setScrollLeft,
])
setAnimationTarget(scroll)
set({ scroll, from: { scroll: scrollLeft } })
}, [animationTarget, scrollLeft, scrollAmountCoef, maxScrollLeft, set])

const handleLeft = useCallback(() => {
if (visibleAreaRef.current === null) {
return
}
const { clientWidth } = visibleAreaRef.current
const scroll = Math.max(scrollLeft - clientWidth * scrollAmountCoef, 0)
setScrollLeft(scroll, true)
set({ scroll, from: { scroll: scrollLeft }, reset: !animation.current })
animation.current = true
}, [animation, scrollLeft, set, scrollAmountCoef, setScrollLeft])
const from = animationTarget ?? scrollLeft
const scroll = Math.max(from - clientWidth * scrollAmountCoef, 0)
setAnimationTarget(scroll)
set({ scroll, from: { scroll: scrollLeft } })
}, [animationTarget, scrollLeft, scrollAmountCoef, set])

// ボタン以外からスクロールされた場合、ボタンによるアニメーションを中断する
const handleAnimationStop = useCallback(() => {
styles.scroll.stop()
}, [styles.scroll])

// スクロール可能な場合にボタンを表示する
// scrollLeftが変化したときに処理する (アニメーション開始時 & 手動スクロール時)
useEffect(() => {
const newleftShow = scrollLeft > 0
const newrightShow = scrollLeft < maxScrollLeft && maxScrollLeft > 0
// 左にスクロール可能 && アニメーション終了時も左にスクロール可能
const newleftShow =
scrollLeft > 0 && (animationTarget === undefined || animationTarget > 0)
// 右にスクロール可能 && アニメーション終了時も右にスクロール可能
const newrightShow =
scrollLeft < maxScrollLeft &&
maxScrollLeft > 0 &&
(animationTarget === undefined || animationTarget < maxScrollLeft)
if (newleftShow !== leftShow || newrightShow !== rightShow) {
setLeftShow(newleftShow)
setRightShow(newrightShow)
onScrollStateChange?.(newleftShow || newrightShow)
}
}, [leftShow, maxScrollLeft, onScrollStateChange, rightShow, scrollLeft])
}, [
animationTarget,
leftShow,
maxScrollLeft,
onScrollStateChange,
rightShow,
scrollLeft,
])

const handleScroll = useCallback(() => {
if (ref.current === null) {
return
}
// 手動でスクロールが開始されたときにアニメーションを中断
if (animation.current) {
styles.scroll.stop()
animation.current = false
}
// スクロール位置を保存 (アニメーションの基準になる)
const manualScrollLeft = ref.current.scrollLeft
// 過剰にsetStateが走らないようにDebouceする
setScrollLeft(manualScrollLeft)
}, [animation, setScrollLeft, styles])

// リサイズが起きたときに、アニメーション用のスクロール領域 & ボタンの表示状態 を再計算する
const handleResize = useCallback(() => {
Expand All @@ -165,24 +168,17 @@ export default function Carousel({
return
}

elm.addEventListener(
'wheel',
handleScroll,
passiveEvents() && { passive: true }
)

const resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(elm)

const resizeObserverInner = new ResizeObserver(handleResize)
resizeObserverInner.observe(innerElm)

return () => {
elm.removeEventListener('wheel', handleScroll)
resizeObserver.disconnect()
resizeObserverInner.disconnect()
}
}, [handleResize, handleScroll])
}, [handleResize])

// 初期スクロールを行う
useIsomorphicLayoutEffect(() => {
Expand Down Expand Up @@ -216,7 +212,11 @@ export default function Carousel({
if (onScroll) {
onScroll(ref.current.scrollLeft)
}
}, [onScroll])
// スクロール位置を保存 (アニメーションの基準になる)
const currentScrollLeft = ref.current.scrollLeft
// 過剰にsetStateが走らないようにDebouceする
setScrollLeft(currentScrollLeft)
}, [onScroll, setScrollLeft])

const [disableGradient, setDisableGradient] = useState(false)

Expand All @@ -239,6 +239,8 @@ export default function Carousel({
ref={ref}
scrollLeft={styles.scroll}
onScroll={handleScrollMove}
// タップされた時を手動スクロール開始とみなして自動スクロールを止める
onTouchStart={handleAnimationStop}
>
<CarouselContainer ref={innerRef} centerItems={centerItems}>
{children}
Expand Down Expand Up @@ -279,6 +281,8 @@ export default function Carousel({
ref={ref}
scrollLeft={styles.scroll}
onScroll={handleScrollMove}
// タップされた時を手動スクロール開始とみなして自動スクロールを止める
onTouchStart={handleAnimationStop}
>
<CarouselContainer ref={innerRef} centerItems={centerItems}>
{children}
Expand Down