본문 바로가기

리액트 네이티브

[인스타그램 클론코딩] 댓글 UI 구현3 - 원하는 컴포넌트 위치로 스크롤

상용 앱을 만들 때 이런 UI는 FlatList를 사용해야 합니다.

FlatList에도 비슷한 기능이 있지만 추가로 고려해야 할 것이 더 있어보입니다.

[인스타그램 클론코딩] 댓글 UI 구현1 - 여러가지 대안들.. 그리고 문제점 포스트에서 ScrollView 안에서 TextInput을 사용하는 여라가지 방법을 테스트해 본 결과, 이 방법을 포기하고, 결국 인스타그램의 댓글창 표시 방법을 카피해 보기로 했습니다. 내부 로직은 다르겠지만, 비슷하게 구현해 봤습니다.

 

댓글 달기... 버튼이 스크롤뷰의 어디에 있든지 댓글 입력창과 겹쳐지는 위치로 오게 만들어야 합니다. 댓글 입력창과 겹쳐지는 위치는 어떻게 구할 수 있을까요?

onLayout 속성을 사용해서 구할 수 있습니다. onLayout 속성으로 부터 컴포넌트가 마운트될 때, 부모 뷰의 좌상단으로 부터 떨어진 거리를 얻을 수 있습니다. 그리고 View를 상속하는 모든 컴포넌트에게 존재합니다.

참조: https://reactnative.dev/docs/view#onlayout

인스타그램 클론코딩 댓글 UI 구현

Post/PostPresenter.js

Post 컴포넌트의 랜더링를 담당하는 컴포넌트입니다.

CommentInputContainer 컴포넌트 안에 댓글 달기...(TouchableOpacity) 버튼이 있습니다.

 

 

 

 

 

 

그렇다면 어떻게 layout.y를 저장할 수 있을까요?

간단합니다. 함수형 컴포넌트를 사용할 때는 Hook을 사용하면 되고, 클래스형 컴포넌트를 사용하는 경우 state 또는 필드를 사용하면 됩니다.

그런데 문제가 있습니다.

layout.y는 스크롤 할 때만 사용되고, 랜더링과 관련이 없습니다.

만약 함수형 컴포넌트에서 Hook을 사용한다면, Post 컴포넌트가 랜더링 되고, layout.y를 저장할 때 다시 (불필요한) 랜더링이 발생할 것입니다. 그리고 랜더링과 관련이 없기 때문에 역시 클래스형 컴포넌트에서도 state를 사용할 필요가 없습니다.

그래서 클래스형 컴포넌트필드를 사용하는 것이 좋습니다.

그런데 또 한 가지 문제가 있습니다. 제가 함수형 컴포넌트에 빠져벼렸다는 것이죠.. 클래스형 컴포넌트에서는 hooks를 사용할 수 없습니다.

그래서 상태 저장용으로 사용할 PostPresenter 클래스형 컴포넌트를 만들고, Home 스크린으로 부터 받은 모든 매개변수를 PostPresenterInside라는 함수형 컴포넌트에 전달해서, 실질적인 랜더링은 함수형 컴포넌트에서 수행하는 방식을 사용했습니다.

==추가==

useRef를 사용하면 클래스 컴포넌트로 래핑하지 않고도 함수형 컴포넌트에서 랜더링과 관련없는 상태를 저장할 수 있습니다.

Post/PostPresenter.js

Container::onLayout에서 얻은 layout.y를 저장할 yPosition CommentInputContainer::onLayout에서 얻은 layout.y를 저장할 yOffset 필드를 정의했고, PostPresenterInside 컴포넌트에서 yPosition과 yOffset의 값을 변경할 수 있도록 setY 함수와 setOffset 함수를 만들어서 PostPresenterInside 컴포넌트에 전달했습니다.

더하여 PostPresenterInside 컴포넌트에서 댓글 달기... 버튼을 ScrollView의 최상단에 위치시킬 수 있는 좌표인 (yOffset + yPosition)얻을 수 있도록 하기 위해 getPosition이라는 함수도 PostPresenterInside 컴포넌트에 전달했습니다. 

class PostPresenter extends Component {
    yPosition = 0;
    yOffset = 0

    setY = (position) => {
        this.yPosition = position;
    }
    
    setOffset = (position) => {
        this.yOffset = position;
    }

    getPosition = () => this.yOffset + this.yPosition

    render() {
        return (
            <PostPresenterInside 
                ...
                setY={this.setY}
                setOffset={this.setOffset}
                getPosition={this.getPosition}
            />
        )
    }
}

export default PostPresenter

이렇게 댓글 달기... 버튼을 ScrollView의 최상단에 위치시킬 수 있는 좌표인 (yOffset + yPosition)Post 컴포넌트별로 저장하고 가져올 수 있습니다.

 

 

 

 

 

 

이제 (yOffset + yPosition)를 사용해서 원하는 위치로 스크롤해 보겠습니다.

ScrollView 컴포넌트는 특정 좌표로 스크롤하는 scrollTo 메소드를 제공합니다.

ScrollTo 메소드는 매개값으로 입력된 좌표를 ScrollView의 좌상단에 위치시킵니다.

이 메소드를 사용해 특정 컴포넌트를 댓글 입력창과 겹쳐지는 위치로 이동시킬 것입니다.

참조: https://reactnative.dev/docs/scrollview#scrollto

Home.js

ScrollView는 Home 컴포넌트에 있습니다.

그리고 ScrollView의 메소드를 코드로 제어하기 위해 useRef를 사용했습니다. useRef 객체인 scrollView가 ScrollView 컴포넌트의 ref 속성에 입력된 것을 확인할 수 있습니다.

그리고 scrollTo 함수는 위치(position)를 받아서 useRef 객체scrollView를 사용하여  해당 위치로 스크롤하는 함수입니다.

백앤드에서 팔로우 상태인 사용자의 Post 객체를 요청했고(생략), 이 정보는 data.seeFeed에 담겨 있습니다.

data.seeFeed에 포함된 각각의 Post 객체에 대하여 Post 컴포넌트를 생성합니다. 이때 scrollTo 함수도 같이 넘겨줍니다.

 

 

 

 

 

 

사실 Post → PostContainer → PostPresenter →PostPresenterInside로 데이터가 전송되지만, PostContainer 부분은 생략했습니다.

Post/PostPresenter.js

이렇게 Home 컴포넌트의 scrollTo 함수는 타고 타고 PostPresenterInside 컴포넌트로 전달됩니다.

이제 PostPresenterInside 컴포넌트 안에 있는 댓글 달기...버튼을 터치하면, PostPresenterInside 컴포넌트 안에서 scrollTo 함수를 사용해서 Home 컴포넌트의 ScrollView를 원하는 위치로 스크롤할 수 있습니다.

 

 

 

 

 

 

이제 OpenComments 컴포넌트의 onPress 속성에서 scrollTo(getPosition())을 사용하면 될까요?

아직 안됩니다. getPosion()을 반환받아 그대로 사용하면 CommentInputContainer 컴포넌트가 스크린 최상단에 위치할 것입니다.

CommentInputContainer 컴포넌트를 맨 아래로 내린 다음, 키보드 높이 만큼 올린 위치를 getPosition 함수의 매개값으로 넣어주어야 합니다.

인스타그램 클론코딩 댓글 UI 구현2

그런데 스크린 높이와 키보드 높이는 어떻게 구할까요?

react-native에서 기본적으로 제공되는 DimensionsKeyboard 모듈을 사용하여 구할 수 있습니다.

단, 키보드 높이는 리스너를 통해서 구하기 때문에, 높이를 구하고 나서 (리소스를 절약하기 위해서) 리스너를 해지시켜 주어야 합니다.

import { Dimensions, Keyboard } from "react-native";

// 스크린 높이 구하기
const { height } = Dimensions.get("screen");

// 키보드 높이 구하기
Keyboard.addListener("keyboardDidShow", (event) => {
    const keyboardHeight = event.endCoordinates.height;
    Keyboard.removeAllListeners("keyboardDidShow"); // 리스너 해지
});

이제 최종적으로 onPress 속성을 어떻게 정의했는지 확인해 보겠습니다.

예상과 달리 원하는 위치에 정확히 맞춰지지 않았습니다. 그래서 플랫폼 별로 platformOffset을 사용하여 약간의 높이 조절을 하였습니다.

Keyboard.addListener 메소드를 사용해도 당장 변화는 없습니다. 키보드가 호출되지 않았기 때문이죠.

하지만 Comment 스크린으로 이동할 때, Comment 스크린 안에 있는 TextInput이 자동으로 포커스되도록 설정해 놨습니다. TextInput에 커서가 생성되면서 키보드가 생성됩니다.

이때 Keyboard.addListener 메소드에 등록했던 콜백함수가 실행되면서 Home 컴포넌트에 있는 ScrollView를 스크롤시킵니다. 그리고 리스너를 해지합니다. 더 이상 필요 없기 때문이죠.

 

 

 

 

너무 길어졌네요..

단순히 코드만 복사하는 것 보다 제가 문제를 해결했던 과정을 알려드리는 것이 좋을 것 같아 제가 코딩했던 과정을 그대로 담아봤습니다.

중복되는 컴포넌트를 부분부분 잘라서 설명했지만, 천천히 살펴보면 전체적인 로직을 파악할 수 있을 것이라고 생각합니다.

전체 코드는 제 github에서 확인할 수 있습니다.