RN 상단 메뉴탭과 스크롤 위치 연동

JunHo Lee·2023년 10월 8일
1

React Native

목록 보기
3/5
post-thumbnail

1. 목표

  • 스크롤에 따라서 상단 메뉴탭이 자동으로 변하는 페이지 구현
  • UI 없이 기능만 구현

2. 아이디어

1) 화면 전체 사이즈를 가지는 여러개의 컴포넌트(섹션)가 있고 그에 따른 스크롤 위치에 따른 상단 메뉴탭 체크 상태 변경
2) 목록이 여러 개 있고 그 위치에 따른 상단 메뉴탭 체크 상태 변경 

3. 구현

2-1) 전체 사이즈 섹션

  • 아이디어

    • 화면 전체 사이즈를 가지는 여러 섹션으로 나누어진 스크롤 가능한 뷰
    • 상단 탭에 섹션 번호가 있으며, 이를 터치하면 해당 섹션으로 스크롤
    • 화면을 스크롤 하여 현재 화면이 어떤 섹션에 위치해 있는지에 따라 상단 탭 버튼의 스타일을 변경하여 현재 섹션 버튼 색 변경
  • 예시

  • 코드

import React, { useRef, useState } from "react";
import {
  View,
  Text,
  StyleSheet,
  ScrollView,
  TouchableOpacity,
  Dimensions,
} from "react-native";

export default function App() {
  // ScrollView를 제어하기 위한 ref
  const scrollViewRef = useRef();
  // 현재 하이라이트된 버튼(현재 어느 섹션을 보고 있는 지)을 추적하는 상태
  const [highlightedButton, setHighlightedButton] = useState(1);

  // 특정 섹션으로 스크롤하는 함수
  const scrollToSection = (sectionNumber) => {
    const yOffset = sectionNumber * Dimensions.get("window").height;
    scrollViewRef.current.scrollTo({ y: yOffset, animated: true });
  };

  // ScrollView의 스크롤 이벤트를 처리하여 현재 섹션 추적
  const handleScroll = (event) => {
    const offsetY = event.nativeEvent.contentOffset.y;
    // 스크롤 위치를 기반으로 현재 섹션 번호 계산
    const sectionNumber =
      Math.round(offsetY / Dimensions.get("window").height) + 1;
    // 하이라이트된 버튼 상태 업데이트
    setHighlightedButton(sectionNumber);
  };

  return (
    <View style={styles.container}>
      {/* 메뉴 영역 */}
      <View style={styles.menu}>
        {[0, 1, 2].map((number) => (
          <TouchableOpacity
            key={number}
            onPress={() => scrollToSection(number)}
            style={[
              styles.menuItem,
              // 현재 섹션과 일치하는 경우 버튼 스타일 변경
              highlightedButton === number + 1 && styles.highlightedButton,
            ]}
          >
            <Text>{number + 1}</Text>
          </TouchableOpacity>
        ))}
      </View>
      {/* 섹션들을 담고 있는 ScrollView */}
      <ScrollView
        ref={scrollViewRef}
        onScroll={handleScroll}
        scrollEventThrottle={16}
      >
        {/* 각 섹션 */}
        <View style={[styles.fullHeightView, { backgroundColor: "lightblue" }]}>
          <Text>Section 1</Text>
        </View>
        <View
          style={[styles.fullHeightView, { backgroundColor: "lightgreen" }]}
        >
          <Text>Section 2</Text>
        </View>
        <View
          style={[styles.fullHeightView, { backgroundColor: "lightcoral" }]}
        >
          <Text>Section 3</Text>
        </View>
      </ScrollView>
    </View>
  );
}

// 스타일 정의
const styles = StyleSheet.create({
  container: {
    paddingTop: 50,
    flex: 1,
    flexDirection: "column",
  },
  menu: {
    flexDirection: "row",
    justifyContent: "space-around",
    backgroundColor: "lightgray",
  },
  menuItem: {
    padding: 10,
    fontSize: 20,
  },
  highlightedButton: {
    backgroundColor: "yellow", // 하이라이트된 버튼의 배경색 변경
  },
  fullHeightView: {
    minHeight: Dimensions.get("window").height,
    alignItems: "center",
    justifyContent: "center",
  },
});
  • 설명
    • useRef를 사용하여 scrollViewRef를 생성하고, 이를 ScrollView에 연결하여 스크롤을 제어
    • useState를 사용하여 highlightedButton의 초기 상태를 1로 설정하고, 버튼의 하이라이트 상태를 관리
    • scrollToSection 함수는 특정 섹션으로 스크롤하기 위해 사용됩니다. 해당 섹션의 높이에 따라 scrollTo 메서드를 사용하여 스크롤
    • handleScroll 함수는 ScrollView의 스크롤 이벤트를 처리합니다. 현재 스크롤 위치를 기반으로 어떤 섹션에 위치해 있는지 계산하고, 그에 따라 highlightedButton의 상태를 업데이트
    • 화면은 View 컴포넌트 안에 ScrollView와 메뉴 버튼들로 구성
    • 메뉴는 0, 1, 2의 섹션을 나타내는 버튼으로 구성되어 있습니다. 각 버튼은 TouchableOpacity를 사용하여 터치 이벤트를 처리하며, 터치된 버튼은 highlightedButton 상태에 따라 스타일이 변경
    • ScrollView에는 3개의 섹션이 포함되어 있으며, 각 섹션은 다른 배경색

2-2) 목록

  • 아이디어

    • 여러개의 목록을 가지는 스크롤 가능한 뷰
    • 상단 탭에 목록의 번호가 있으며, 이를 터치하면 해당 목록으로 스크롤
    • 화면을 스크롤 하여 현재 화면이 어떤 목록이 위치해 있는지에 따라 상단 탭 버튼의 스타일을 변경하여 현재 섹션 버튼 색 변경
  • 예시

  • 코드

// [App.js]
import React, { useRef, useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import MultiVerticalList from "./MultiVerticalList";

const YourScreen = () => {
  // ScrollView를 조작하기 위한 ref
  const scrollViewRef = useRef();
  // 현재 하이라이트된 버튼(현재 어느 목록을 보고 있는 지)을 추적하는 상태
  const [highlightedButton, setHighlightedButton] = useState(1);

  // 더미 목록 데이터
  const data = [
    {
      title: "List 1",
      items: Array.from({ length: 50 }, (_, i) => ({
        id: i,
        title: `Item ${i}`,
      })),
    },
  ];

  // 목록으로 스크롤하는 함수
  const scrollToSection = (sectionIndex) => {
    setHighlightedButton(sectionIndex);
    if (scrollViewRef.current) {
      scrollViewRef.current.scrollToIndex({
        animated: true,
        index: sectionIndex,
      });
    }
  };

  // 상단 메뉴에 표시될 목록 인덱스 배열
  const topMenu = [0, 15, 32];

  return (
    <View style={styles.container}>
      {/* 상단 메뉴 */}
      <View style={styles.menu}>
        {topMenu.map((number, i) => (
          <TouchableOpacity
            key={number}
            onPress={() => scrollToSection(number)}
            style={[
              styles.menuItem,
              // 강조된 버튼 스타일링
              i < topMenu.length - 1
                ? highlightedButton >= number &&
                  styles.highlightedButton &&
                  highlightedButton < topMenu[i + 1] &&
                  styles.highlightedButton
                : highlightedButton >= number && styles.highlightedButton,
            ]}
          >
            <Text>{number}</Text>
          </TouchableOpacity>
        ))}
      </View>
      {/* 세로로 여러 리스트를 표시하는 컴포넌트 */}
      <MultiVerticalList
        ref={scrollViewRef}
        data={data}
        // 스크롤 시 강조된 버튼 업데이트
        onScroll={(index) => setHighlightedButton(index)}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    marginTop: 50,
    marginBottom: 50,
    flex: 1,
    flexDirection: "column",
  },
  menu: {
    flexDirection: "row",
    justifyContent: "space-around",
    backgroundColor: "lightgray",
  },
  menuItem: {
    padding: 10,
    fontSize: 20,
  },
  highlightedButton: {
    backgroundColor: "yellow",
  },
});

export default YourScreen;

// [MultiVerticalList.jsx]
import React, { forwardRef, useRef } from "react";
import { View, FlatList, Text } from "react-native";

const MultiVerticalList = forwardRef(({ data, onScroll }, ref) => {
  const flatListRef = useRef();

  // 스크롤 이벤트 핸들러
  const handleScroll = (event) => {
    const offsetY = event.nativeEvent.contentOffset.y;
    // 현재 스크롤 위치에 해당하는 섹션의 인덱스 계산
    const index = findSectionIndex(offsetY);
    if (onScroll) {
      onScroll(index);
    }
  };

  // 주어진 스크롤 위치에 해당하는 섹션 인덱스 찾기
  const findSectionIndex = (offsetY) => {
    let index = 0;
    for (let i = 0; i < data[0].items.length; i++) {
      if (offsetY < i * 40) {
        break;
      }
      index = i;
    }
    return index;
  };

  // 각 리스트 아이템 렌더링 함수
  const renderList = ({ item }) => (
    <View
      style={{ marginVertical: 10 }}
      onLayout={(event) => {
        // 리스트 아이템의 레이아웃 정보를 얻어오기
        const layout = event.nativeEvent.layout;
        const position = layout.y; 
      }}
    >
      <Text>{item.title}</Text>
    </View>
  );

  // 리스트 아이템 간 구분선 렌더링 함수
  const renderSeparator = () => (
    <View style={{ height: 1, backgroundColor: "#ccc" }} />
  );

  return (
    // FlatList 컴포넌트로 섹션과 아이템 표시
    <FlatList
      ref={(listRef) => {
        flatListRef.current = listRef;
        // 부모 컴포넌트에서 ref 사용 가능하도록 설정
        if (ref) {
          ref.current = listRef;
        }
      }}
      data={data.flatMap((listData) => listData.items)}
      renderItem={renderList}
      keyExtractor={(item) => item.id.toString()}
      ItemSeparatorComponent={renderSeparator}
      // 스크롤 이벤트 처리
      onScroll={handleScroll}
      scrollEventThrottle={16}
    />
  );
});

export default MultiVerticalList;
  • 설명
    • App.js
      • scrollViewRef는 MultiVerticalList 컴포넌트를 스크롤하기 위한 ref를 가지고 있음
      • highlightedButton은 메뉴 버튼의 현재 강조된 인덱스
      • data 배열은 더미 목록 데이터
      • scrollToSection 함수는 메뉴 버튼을 터치하면 해당 목록으로 스크롤하도록 하는 함수
      • topMenu 배열은 메뉴에 표시될 섹션의 인덱스
    • MultiVerticalList 컴포넌트:
      • forwardRef를 사용하여 부모 컴포넌트에서 MultiVerticalList에 대한 ref를 사용할 수 있음
      • handleScroll 함수는 리스트가 스크롤될 때 호출되어 현재 스크롤 위치에 해당하는 목록의 인덱스를 찾음
      • findSectionIndex 함수는 주어진 스크롤 위치에 대한 목록 인덱스를 계산
      • renderList 함수는 각 리스트 아이템을 렌더링하고, renderSeparator 함수는 리스트 아이템 사이에 구분선을 추가
      • FlatList 컴포넌트를 사용하여 섹션과 아이템들을 표시하고, 스크롤 이벤트를 처리

4. 아쉬움

  • 고정된 컴포넌트의 사이즈를 가지고 이동하고 파악하기 때문에 구성하는 내용이 달라질 때에는 적용하기 어려움
    • 각 목록의 사이즈를 불러와서 이동하는 부분이 필요

0개의 댓글