React Compound Component Pattern 을 이용해서 Masonry UI 만들기 [2]

Jaewoong2·2022년 7월 23일
1

작성중

Why Compound Pattern?

고려 사항

NPM 라이브러리로 만들기 위해서 Masonry Gallery UI 를 구현 하기 때문에, DX 를 위해서 Masonry Gallery UI 를 사용 하기 위해서 다른 Provider 또는 다른 컴포넌트를 사용하지 않으려고 한다

2. Masonry UI 를 적용 하려면 각 이미지에 접근해, 레이아웃을 변경 해야한다

Gallery 에서 하위 Image 컴포넌트에 접근해서 Image Height 에 맞춰서 Masonry Gallery UI Layout 을 변경 해야한다.

Compound Component 를 이용한, 해결 방법

  • Gallery.Image 등을 통해서 Gallery 에서 사용하는 Image 라는 것을 명시적으로 표현 할 수 있게 하고, 다른 컴포넌트를 Import 하지 말자

2. Masonry UI 를 적용 하려면 각 이미지에 접근해, 레이아웃을 변경 해야한다

  • Gallery.Image 에서 GalleryContextaddItemRefs 를 사용해서 상위 Gallery 컴포넌트의 상태 itemRefsItemRef 들을 추가한다.

    • itemRef 는 각 Imageref 들을 갖고 있는 배열 이며, 상위 Gallery 에서 각 이미지 값에 접근해서 Height 를 얻을 수 있도록 한다.

    • Gallery.Image 가 렌더링 되면, addItemRefs 를 실행 시켜 Imageref 값을 업데이트 시킨다.

    • Image Height 에 따라서 Gallery 의 Layout 을 변경 시킨다

이미지 및 Masonry 레이아웃 관련 처리

  • Progressive Image

    • IntersectionObserver API 사용
  • Masonry Layout 로직

  • 해당 로직은 각 이미지가 로드 될 때, 업데이트 되도록 한다.

    export const getGridRowEnd = (containerStyle: CSSStyleDeclaration, element: HTMLElement) => {
      const columnGap = parseInt(containerStyle.getPropertyValue('column-gap'))
      const autoRows = parseInt(containerStyle.getPropertyValue('grid-auto-rows'))
      const captionHeight = element.querySelector('.caption')?.scrollHeight ?? 0
      const imageHeight = element.querySelector('.figure')?.scrollHeight ?? 0
      const spanValue =
        captionHeight > 0
          ? Math.ceil((imageHeight + captionHeight) / autoRows + columnGap / autoRows) - 5
          : Math.ceil((imageHeight + captionHeight) / autoRows + columnGap / autoRows)
    
      return `span ${spanValue}`
    }
  • Masnory Gallery UI Wrapper CSS

     --gap: 10px;
      width: 100%;
      height: 100%;
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      column-gap: var(--gap);
      grid-auto-rows: var(--gap);
      @media screen and (max-width: 1024px) {
        grid-template-columns: repeat(3, 1fr);
      }
      @media screen and (max-width: 720px) {
        grid-template-columns: repeat(2, 1fr);
      }
      @media screen and (max-width: 400px) {
        display: block;
        width: 100%;
      }
    

구현 코드

Gallery.tsx

const initialValue: GalleryContextType = {
  addItemRefs: () => {},
  handleLayout: () => {},
}

const GalleryContext = createContext(initialValue)

export const Gallery = ({ children, isMobile }: PropsWithChildren<GalleryProps>) => {
  const ref = useRef<HTMLDivElement>(null)
  const [itemRefs, setItemRefs] = useState<React.RefObject<HTMLAnchorElement>[]>([])

  const handleLayout = useCallback(() => {
    if (isMobile) return
    itemRefs.forEach((itemRef) => {
      if (!itemRef.current || !ref.current) {
        return
      }
      const masonryContainerStyle = getComputedStyle(ref.current)
      itemRef.current.style.gridRowEnd = getGridRowEnd(masonryContainerStyle, itemRef.current)
    })
  }, [ref, itemRefs, isMobile])

  // Trade-off between UX and Performance
  const debouncedFunction = useDebouncedCallback(handleLayout, 200)
  useWindowResize(debouncedFunction, [ref.current, itemRefs, debouncedFunction])

  useEffect(() => {
    handleLayout()
  }, [ref, itemRefs, debouncedFunction])

  return (
    <GalleryContext.Provider
      value={{
        addItemRefs: (entitiy) => setItemRefs((prev) => [...prev, entitiy]),
        handleLayout: debouncedFunction,
      }}
    >
      <ImageContainer size={isMobile ? 'mobile' : undefined} ref={ref}>
        {children}
      </ImageContainer>
    </GalleryContext.Provider>
  )
}

Gallery.Image = GalleryImage

Context API

const initialValue: GalleryContextType = {
  addItemRefs: () => {},
  handleLayout: () => {},
}
  • addItemRefs : 이미지 컴포넌트 가 렌더 될 때 해당 이미지의 Element 를 추가하기 위한 함수
  • handleLayout : 이미지가 로드 완료 될 때 Layout을 변경 시키는 함수를 실행

handleLayout

const handleLayout = useCallback(() => {
  if (isMobile) return
  itemRefs.forEach((itemRef) => {
    if (!itemRef.current || !ref.current) {
      return
    }
    const masonryContainerStyle = getComputedStyle(ref.current)
    itemRef.current.style.gridRowEnd = getGridRowEnd(masonryContainerStyle, itemRef.current)
  })
}, [ref, itemRefs, isMobile])
  • 레이아웃을 변경 시키는 함수

  • 이미지를 감싸는 컴포넌트와 하위 이미지들의 스타일 속성들을 통해서 이미지의 gridRowEnd 의 값을 이미지에 맞게 업데이트

구현 코드

GalleryImage

// Gallery.Image, It can be setted in <Gallery></Gallery>
const GalleryImage = ({ src, children, href }: React.ImgHTMLAttributes<HTMLImageElement> & { href?: string }) => {
  const { addItemRefs, handleLayout } = useContext(GalleryContext)
  const [imageSrc, setImageSrc] = useState<string>()

  // Ref for Gallery Entities State
  const ref = useRef<HTMLAnchorElement>(null)

  // Ref for Intersection Obeserver
  const imageRef = useRef<HTMLImageElement>(null)

  const [entry, observer] = useIntersectionObserver(imageRef)

  // For Progressive image
  useEffect(() => {
    if (entry?.isIntersecting) {
      const target = entry.target as HTMLImageElement
      setImageSrc(target.dataset.src)
      observer?.unobserve(entry.target)
      // After Image Content Loaded, handleLayout 
      handleLayout()
    }
  }, [entry, observer])

  useEffect(() => {
    addItemRefs(ref)
  }, [ref])

  return (
    <AnchorContainer href={href} ref={ref} className="masonry-item">
      <Figure className="figure">
        <Image
          ref={imageRef}
          className={children ? 'img' : 'img no-caption-img'}
          data-src={src}
          src={imageSrc}
          isLoading={false}
        />
        <figcaption className={children ? 'caption' : ''}>{children}</figcaption>
      </Figure>
    </AnchorContainer>
  )
}

사용

import { Gallery } from '@jaewoong2/dui'

const MasonryGallery = () => (
    <Gallery>
      <Gallery.Image href="/" src={shortSrc}>
        <div>Hello</div>
      </Gallery.Image>
      <Gallery.Image src={longSrc}>
        <div>Hello</div>
        <div>Hello</div>
        <div>Hello</div>
      </Gallery.Image>
      <Gallery.Image src={shortSrc} />
      <Gallery.Image src={longSrc} />
      <Gallery.Image src={shortSrc} />
      <Gallery.Image src={longSrc} />
      <Gallery.Image src={shortSrc} />
    </Gallery>
)

결과


profile
DFF (Development For Fun)

0개의 댓글