[Flutter] 땡기는 인터렉션

LOCKED·2021년 11월 26일
32

Interaction

목록 보기
2/7
post-thumbnail

이번 포스트에서는 배달의 민족에서 사용되고 있는 '땡겨요' 인터렉션을 구현해볼 것이다.

배민, 땡겨요

배달의민족 메인화면

배달의민족 메인화면의 땡겨요 인터렉션

배민의 메인화면에서 화면을 밑으로 당기게되면 이번 포스트에서 다룰 주제가 나타난다.

원래 화면을 위에서 아래로 당기는 행위는 pull to refresh 라고도 부르는데 이 기능은 보통 화면을 새로고침하는 기능을 한다.

배달의 민족은 여기에 당기다땡기다의 언어유희를 사용하여 새로고침뿐만 아니라 사용자에게 추천 음식 메뉴도 제공해준다.

그리고 계속 내리면 센스가 돋보이는 문구가 뜬다

기본 레이아웃

위에서 본 배민의 메인화면의 간략한 레이아웃을 잡으며 시작하겠다.
(조금 오래되긴했지만)변경된 배민 앱을 분석하고 화면 영역을 주관적인 기준으로 나눠보았다.
화면은 위에서부터 앱바, 땡겨요, 검색바, 그리드뷰가 위치해있다.

Scaffold(
  appBar: const MyAppBar(),
  body: ListView(
    children: [
      const MyPullWidget,
      const MySearchBarWidget,
      const MyGridWidget,
    ],
  ),
);

이 포스트에서는 당겨지는 영역(땡겨요)에 대해서만 다뤄볼 것이다.
...

땡기는 리스트

이번 포스트를 작성한 이유가 이 위젯 영역에 담겨있다.
땡겨요라는 표현을 잘 사용한 것 같다.

지난번 우연히 배달의 민족 CMO의 강연을 들으며 느꼈지만 배달의 민족은 배민선물하기에서 볼 수 있듯이 이런 언어유희를 잘 사용하는 것 같다.
...

어떻게할까?

먼저 개발을 진행하기 앞서, 어떻게 해야 이 기능을 구현할 수 있을지 명확하게 하기위해서 배민의 메인화면에서 여러번 이 기능을 사용해보았다.

기능은 위에서도 잠깐 언급했지만, Pull to Refresh의 기능을 기본으로 한다.

플러터에서도 해당 기능을 사용할 수 있는 위젯이 존재하니 이를 참고해도 좋을 것 같다

이벤트가 발생한 순서대로 작성해보자면,

1. 화면을 아래로 당기는 유저의 이벤트가 발생

  • 당겨진만큼 화면이 밑으로 늘어남
  • 화면이 늘어나는 동안 음식 리스트가 당겨지는 속도와 비례하며 회전 함.

2. 화면을 아래로 당기던 유저의 이벤트가 멈춤

  • 음식리스트가 반대방항으로 회전하며 마지막에는 텍스트를 노출한다.

3. 일정 시간이 지남

  • 늘어났던 화면이 원상복구 됨
  • 데이터 초기화

또한 매번 음식리스트의 순서는 일정하지 않고 추천되는 결과 또한 매번 다르다.

구현을 한다면

사실 해당 기능은 배민 앱 내에 숨겨진 기능이라고 볼 수 있을 것 같다. 숨겨진 기능임에도 신경써서 만든 것이 느껴지는데, 화면에서 차지하는 영역도 작고, 이 기능으로 사용자가 얻는건 사실 저녁식사메뉴보단 재미에 가까운 것 같다.
앱의 브랜딩을 위한 기능이라 생각된다..

그럼 이 사소하지만 복잡해보이기도하는 기능은 어떻게 구현할까??

뭐부터 해야할지 감이 안잡힌다면 할 수 있는 걸 찾아보자.

당장 할 수 있는 일

가장 쉬우면서도 당장 할 수 있는 일이라고 하면 먼저 이미지 구하기일 것이다.
기능을 만들기 위해서는 회전하는 리스트에 들어갈 이미지가 필요한데, 그냥 텍스트로 할까, 어떤 이미지 셋을 사용할까 고민하다 배달의 민족의 음식 사진을 똑같이 사용하려 한다.
...

실제 개발에서는 가장 나중에 하는 일이다.

이미지는 배민에서 열심히 스크린샷을 찍어가며 구했다

난 햄버거가 땡긴다...

땡기는 리스트뷰

리스트뷰를 만들기 위해서는 ListView 위젯을 사용하면 된다.
이 위젯에 있는 파라미터인 physics으로 Scroll Physics를 적용할 수 있다.
iOS는 BouncingScrollPhysics, AOS는 ClampingScrollPhysics가 기본 값이다.

먼저 iOS의 기본 바운스 효과를 없애기 위해 ClampingScrollPhysics로 설정한다.

AOS 경우 ListView에 Glow Effect가 적용되어 있으니 이것도 제거하자

ListView(
  physics: const ClampingScrollPhysics(),
),

BouncingScrollPhysics 는 스크롤이 늘어난만큼 음수값을 반환해줘서 스크롤이 얼마나 되었는지를 알 수 있었다.
그런데 ClampingScrollPhysics 설정을 하게 되면, 스크롤 포지션 바운더리가 음수 값을 포함하지 않는다.

그렇기 때문에 여기서 Listener 위젯을 사용하여 사용자의 제스쳐를 파악하였다.

Listener(
  onPointerDown: (PointerDownEvent _){},
  onPointerMove: (PointerMoveEvent _){},
  onPointerUp: (PointerUpEvent _){},
  child: Scaffold(),
),

해당 위젯에는 pointer 이벤트 함수들이 있다.
각 이벤트들은 이름이 명시한 시점에 호출된다.

onPointerMove

사용자가 밑으로 내린 만큼의 Offset을 구하고 해당 크기만큼 영역을 늘려준다.

onPointerUp

늘어났던 영역은 줄어들게 되고, 음식사진이 반대방향으로 회전하며 멈춘다.

onPointerDown

데이터를 초기화한다.

음식 사진 리스트 셔플과 같은 일들을 처리 함.

동적 영역

Listener 위젯을 통해 받은 이벤트를 토대로 동적영역을 구축한다.

동적영역을 구현하는 방법은 2가지가 있다.

  1. AnimationController 를 사용한 영역 증감
  2. AnimatedContainer 위젯을 사용한 영역 증감

이번 포스트에서는 좀 더 쉬운 방법인 2번째 방법을 예시로 보여줄 것이다.

// MyPullWidget
AnimatedContainer(
  height: pullHeight,
  duration: const Duration(milliseconds: 300),
  child: child,
),

AnimatedContainer 위젯을 사용하게되면 내부 파라미터의 변화가 있을 때, 자동으로 애니메이션
을 적용한 변화를 보여준다.

그리고 위에서 선언한 Listener 위젯의 이벤트들을 만들면 된다.

double scrollVelocity = 2.4;

onPointerMove(PointerMoveEvent _){
  if (pullHeight + _.delta.dy < 0) return;
  scrollController.jumpTo(pullHeight * scrollVelocity);
  setState(() {
    pullHeight += _.delta.dy;
  });
}

사용자의 이벤트를 받아 pullHeight를 증감한다.
또한 이 증감되는 수치의 스크롤 속도(scrollVelocity)값을 곱하여 음식 리스트 뷰를 움직인다.

onPointerUp(PointerUpEvent _ ){
  if (pullHeight > contentHeight) {
    displayImageList.insert(0, foodList[0]);
    setState(() {
      pullHeight = contentHeight;
    });
    
    await scrollController.animateTo(0, duration: const Duration(milliseconds: 1500), curve: Curves.easeOut);
    Timer(const Duration(milliseconds: 800), () {
      if (pullHeight == contentHeight && scrollController.offset == 0) {      
        setState(() {
          pullHeight = 0;
        });
      }
    });
  }else{
    setState(() {
      pullHeight = 0;
    });
  }
}

사용자가 이벤트가 끝났을 때, 먼저 일정 이상 높이까지 내렸는지를 확인한다.
일정이상 높이까지 내려 갔다면, 위에서 설계한 로직을 실행한다.
일정이상 높이까지 내리지 않았다면, 높이를 0으로 바꾼다.

여기서 foodList는 음식의 한글이름 리스트이며 0번째에 삽입하여 사용자에게 노출되게 한다.

해당 리스트는 onPointerDown 이나 Timer가 끝난이후 shuffle해주자
...

결과

세세한 설명은 생략했지만, 핵심기능의 개발이 생각보다 간단하게 끝나게 되었다.
이는 AnimationController를 사용하지 않고 AnimatedContainer를 활용하였기 때문이라고 할 수 있을 것이다.


이번 글의 주제처럼, 앱 내에 센스있게 스며든 인터렉션은 참 매력적인 것 같다.
앱을 사용하면서 이러한 숨겨진 기능을 발견하는 재미가 쏠쏠하다.

좀 더 궁금하다면, 댓글을 남겨주세요.

profile
Flutter 개발자 :'>

2개의 댓글

comment-user-thumbnail
2024년 1월 16일

너무 구현하고 싶은 코드인데 구체적인 예시 코드가 없어서 너무 어렵네요 ㅠㅠ 혹시 깃헙에 예시 코드가 있을까요?ㅠ

답글 달기
comment-user-thumbnail
2024년 1월 16일

혹시 마지막에 한글이 뜨는건 한글도 이미지 파일로 만드신건가요? 아니면 String 타입의 문자열 그자체 인가요?

답글 달기