삼쩜삼 Flutter앱에 토스의 터치 인터렉션 끼얹기

Ximya(심야)·5일 전
10
post-thumbnail

삼쩜삼에서 세금 환급 서비스를 이용해 보신 적이 있으신가요?

삼쩜삼 서비스 초창기 때 세금 삼쩜삼에서 꽤 쏠쏠한 금액을 환급 받아서 기분이 좋았던 기억이 납니다. 당시에는 웹 기반으로만 서비스를 제공했던 것으로 기억합니다. 근데 최근에, Flutter로 개발된 쌈점삼 앱이 있다는 사실을 알게 되어 직접 다운로드 받아 이것저것 살펴보았습니다.

토스랑 유사한데...?

삼쩜삼 앱에 대한 제 첫인상은 토스앱과 UI나 UX적으로 굉장히 유사하다는 점이었는데요.

두 앱 모두 카드 UI3D 형태의 GUI를 적극적으로 활용한다는 점이 닮아 있었고, 더더욱 금융이라는 카테고리에 있는 두 앱이 두 앱 모두 파란색 계열의 브랜드 컬러를 채택하하고 있다는 점 때문에 더욱 비슷한 느낌을 주는 것 같았습니다.

토스의 터치 인터렉션 (애니메이션)

UI는 닮아 있지만, UX 측면에서는 삼쩜삼과 토스 앱이 명확히 구분되는 부분이 하나 있었습니다. 바로 터치 인터랙션입니다.

토스 앱에서는 터치 가능한 대부분의 UI 요소(카드, 버튼 등)에 터치 애니메이션이 적용되어 있습니다. 사용자가 UI를 누르면 요소가 살짝 움츠러들었다가, 터치가 해제되는 시점에 다시 원래 크기로 돌아오는 인터렉션이 고려되어 있습니다.

이런 인터랙션은 사소해 보일 수 있지만, 모바일 환경에서는 마우스 커서가 없는 대신 터치 영역이 한정적이라는 점을 보완하며 앱을 더 생동감 있고 매력적으로 만들어 준다고 생각합니다. 이러한 이유로 여러 앱스토어, 슬랙, 카카오톡 등등 여러 빅테크 앱에서도 이런 터치 인터렉션들을 적극 사용하고 있는 것 같기도 하고요.

그래서 이번 포스팅에서는 삼쩜삼앱에 토스의 터치 인터렉션 로직을 적용할 수 있는 모듈을 차근차근 단계별로 만들어보려고 합니다.

토스와 유사한 터치 인터렉션을 간편하게 적용할 수 있도록 도와주는 bounce_tapper 패키지를 개발하였습니다. 여러분의 프로젝트에 풍분한 터치 인터렉션을 적용하고 싶으시다면 해당 패키지를 사용하시길 적극 권장드립니다 😀


1. Listener 위젯을 활용해 터치 제스쳐 활성화 및 해제 시점 구분하기

삼쩜삼앱 곳곳에서 사용되고 있는 '환급 받기' 버튼(FilledButton)을 예시로 터치 애니메이션을 적용해 보려고 합니다.

먼저, 터치 시 위젯이 움츠러들고 손가락이 화면에서 떼어질 때 본래 크기로 돌아오는 애니메이션을 구현하려면 터치 시작터치 해제 시점을 정확히 파악해야 합니다. 그 이후에 각 시점에 필요한 애니메이션을 적용하면 될 것 같습니다.

GestureDetector( 
 onTapDown: (_) {  
   log('onTapDown');  
    },  
  onTapUp: (_) {  
    log('onTapUp');  
  }, 
  child: YourWidget(),
)

일반적으로 여러 제스쳐 처리하는데 많이 주로 사용하는 GestureDetectors 위젯의 onTapDown, onTapUp과 같은 콜백 메서드를 통해 터치가 시작되고 해제되는 시점을 처리할 수 있겠습니다.

  • onTapDown: 위젯이 처음 터치될 때 호출됨
  • onTapUp: 화면에서 터치가 해제될 때 호출됨
GestureDetector( 
 onTapDown: (_) {  
    log('onTapDown');  
    },  
  onTapUp: (_) {  
    log('onTapuUp');  
  }, 
  child: FilledButton(  
    onPressed: () {  
      log('FilledButton > onPressed');  
    },
   child : Text('환급받기')
  ),
)

하지만, 터치 제스처를 지원하는 위젯(FilledButton, Material, Inkwell 등)을 GestureDetector로 감쌌을 때는 제스처 콜백 이벤트가 정상 작동하지 않는 문제가 발생합니다. 반면, 터치 이벤트가 없는 위젯(예: Container)으로 감쌌을 경우에는 정상적으로 작동하는데 말이죠. 이 문제를 로그를 통해 조금 더 명확히 확인해 볼 수 있습니다.

//// Gesture Detector로 FilledButton로 감싸져 있을 때
[log] FilledButton > onPressed

/// 터치 제스쳐를 실행시키지 않는 Container로 감싸져 있을 때
[log] onTapDown  
[log] onTapuUp  
[log] FilledButton > onPressed

로그를 보면, Container를 터치했을 때는 모든 제스처 이벤트가 작동하지만, FilledButton을 터치했을 때는 onPressed 로그만 출력되게 됩니다.

왜 이렇게 작동할까요?

그 이유는 플러터의 제스처 처리 방식에 있습니다. 플러터에서는 하나의 터치 이벤트에 대해 응답하는 위젯이 단 하나만 존재합니다. 즉, 터치 위치에 중복된 위젯들이 있더라도 최종적으로 한 위젯만 이벤트를 처리하는 것이죠.

예를 들어 3가지의 A, B, C 컨테이너가 순서대로 감싸져 있고 각각 GestureDetector에 onTap 제스쳐에 특정 이벤트가 특정된다고 가정해 보겠습니다. 여기서 가장 하위 위젯인 C 컨테이너를 사용자가 터치했을 때 때 A,B의 onTap은 트리거 되지 않을 겁니다.

어떻게 보면 당연해 보이지만, GestureDetector 위젯은 내부적으로 여러 복잡한 알고리즘을 거쳐 이러한 터치 동작을 지원합니다. 조금 간단하게 말씀드리자면 터치 영역이 중복된 여러 위젯중 이벤트에 응답하는 위젯을 결정하기 위해 Flutter는 Gesture Arena(경기장)에서 여러 규칙을 통해 승자를 결정하는데요. 그리고 이 Arena에서 가장 첫 번째 승리 원칙은 상위 위젯은 자식 위젯의 Tap Gesture를 이길 수 없다는 것입니다. 그러므로 여기서는 가장 하위 위젯에 속해 있는 'C' 컨테이너가 승자가 되는 것이죠.

그래도 여전히 이 문제를 해결할 방법은 있습니다.
Gesture Arena라는 경기장의 규칙에서 벗어나 필요한 터치 제스쳐 콜백을 실행하기 위해 Listener 위젯을 사용하는 것입니다.

Listener(  
  onPointerDown: (_) {  
    ...  
  },  
  onPointerUp: (_) {  
    ...  
  },  
  child: FilledButton(  
    onPressed: () {  
      ...  
    },  
    child: Text(  
      '환급 받기',  
    ),  
  ),  
),

Listener 위젯은 GestureDetector보다 Raw한 레벨에서 터치 이벤트를 감지하기 때문에 하위 위젯의 제스처와 충돌 없이 터치 시작(onPointerDown)과 해제(onPointerUp) 이벤트를 실행할 수 있게 됩니다.

RawGestureDetector을 이용하여 GestureArena의 알고리즘을 재설정하는 것도 하나의 방법이 입니다. 다만 개인적으로 Listener를 사용하는 것이 훨씬 더 직관적이고 코드가 간결했습니다.


2. 부드러운 축소/확대 애니메이션 적용

이제 위젯이 터치되거나 해제되는 시점을 파악했으니, 각 시점에 적합한 축소/확대 애니메이션을 적용해 보겠습니다. AnimationScale 위젯을 사용하면 간단하게 Scale 애니메이션을 적용할 수 있지만, 이후 추가할 애니메이션(예: 터치 시 하이라이트 효과)을 고려해 AnimationController, AnimatedBuilder, Transform.scale 조합하는 방식을 적용하려고 합니다.

late final AnimationController animationController;  
late final Animation<double> _scaleValue;  
  
  
void initState() {  
  super.initState();  
  animationController = AnimationController(  
    vsync: this,  
    duration: const Duration(milliseconds: 160),  
    reverseDuration: const Duration(milliseconds: 120),  
  );  
  _scaleValue = Tween(begin: 1.0, end: 0.965).animate(  
    CurvedAnimation(  
      parent: _controller,  
      curve: Curves.easeInSine,  
      reverseCurve: Curves.easeOutSine,  
    ),  
  );  
}
  ...

우선 AnimationController 초기화 시켜주어야 합니다. TickerProvider(this) 객체와 애니메이션 재생 시간(duration), 역재생 시간(reverseDuration)을 설정하여 AnimationController를 초기화 해주었습니다.

TickerProvider 객체를 전달하려면 SingleTickerProviderStateMixin을 믹스인해야 합니다. 플러터 Hooks를 사용 중이라면 useAnimationController를 활용해 컨트롤러를 선언해도 됩니다.

그 다음 애니메이션의 값 변화를 정의하기 위해 Tween 객체를 사용하여, 시작 값(begin)과 끝 값(end)을 설정해주었는데요. 애니메이션을 적용하려고 하는 카드뷰의 기본 크기에서 0.965배로 축소되도록 하기 위해 시작 값을 1.0, 끝 값을 0.965로 설정한 뒤 추가적으로 Curved 애니메이션을 속성을 통해 애니메이션이 실행될 때 부드럽게 크기가 줄어드는 부분을 고려했습니다.

return AnimatedBuilder(  
  animation: _controller,  
  builder: (context, child) {  
    return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          animationController.forward();   
 .       } 
        onPointerUp: (_) {   
           animationController.reverse();   
 .       } 
        child: FilledButton(  
          onPressed: () {},  
          child: const Text('환급 받기'),  
        ),  
      ),  
    );  
  },  
);

이후 Listener의 제스쳐 콜백 이벤트에 animationController forward, reverse 메소드를 통해 animation value를 조작하는 메소드를 적절히 설정해 주고,Transform.scale위젯에 해당 value를 전달해 주면 됩니다. 추가적으로 AnimatedBuilder를 사용하여 Animiation이 실행될 때마다 위젯의 렌더링이 될 수 있도록 설정해 주는 부분도 꼭 고려해 주어야 합니다.

그리고 위젯을 한번 터치해 보면 위젯이 터치되는 시점에는 위젯의 크기가 줄어들고 해제되는 순간에 다시 원래 크기로 돌아어오는 애니메이션이 적상적으로 작동되는 것을 확인하실 수 있습니다.

하지만, 일반적인 짧은 순간 탭을 하는 경우 onPointerDownonPointerUp이 연달아 호출되어 축소 애니메이션이 끝나기도 전에 다시 확대됩니다. 위젯이 축소되었다가 찰나의 순간 다시 원래 크기로 돌아오기 때문에 터치 애니메이션 실행된다는 것조차 알아차리기 힘들 수 있죠.

계속 예시로 들고 있는 토스앱의 터치 인터렉션을 유심히 살펴보시면 위젯이 완전히 축소된 이후에 확대 애니메이션이 실행되는 것을 알 수 있습니다.

이처럼 유사한 애니메이션을 적용하기 위해 축소 애니메이션이 끝난 후에 확대 애니메이션을 실행하도록 조정할 필요가 있어 보입니다.

 return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          _controller.forward();   
 .       } 
        onPointerUp: (_) async{   
           await _controller.forwrad();
           _controller.reverse();   
 .       } 
        child: FilledButton(  
          onPressed: () {},  
          child: const Text('환급 받기'),  
        ),  
      ),  
    );  

그리고 이걸 꽤 간단하게 해결할 수 있습니다. onPointerUp안 revers() 메소드가 실행되기 전에, 축소되는 애니메이션이 종료될 때까지 기다리는 'await _controller.forward()' 메소드만 추가해 주면 됩니다.

그럼이 이제 축소 애니메이션이 마무리 된 이후 자연스럽게 확대되는 애니메이션이 적용되게 됩니다.


3. 터치 이벤트 중복호출 방지

자체적으로 터치 제스쳐 이벤트를 실행시키는 FilledButton과 같은 위젯에 애니메이션을 적용할 때도 있지만, 터치 제스처가 없는 위젯도 고려해야하므로 onTap 메소드를 별도로 실행시켜 주는 로직을 추가해야 합니다.

 return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          _controller.forward();   
 .       } 
        onPointerUp: (_) async{   
           await _controller.forwrad();
           onTap(); // <-- onTap 이벤트 실행!
           _controller.reverse();   
 .       } 
        child: TaxRefoundCard();
      ),  
    );  

일반적으로 터치가 해제되는 시점에 onTap를 실행시키기 터치가 해제될 때 트리거되는 onPointerUp 메소드 onTap이벤트를 할당해 주었습니다.

하지만 onTap를 실행하는 과정에서 또 다른 문제점이 발생합니다 😢

만약 터치 애니메이션이 적용된 위젯을 짧은 시간 동안 연속적으로 탭하거나, 거의 동시에 여러 위젯을 탭하면 ListeneronPointerUp , onPointerDown 제스처가 여러 번 트리거되며 onTap 이벤트가 중복 호출되는 문제가 발생하게 됩니다.

어떻게 이 문제를 해결할 수 있을까요?

AnimationController 상태를 활용한 중복 호출 방지

다행히 애니메이션 상태를 나타내는 AnimationController의 상태값을 활용하면, 중복 호출 여부를 판단할 수 있습니다. 애니메이션이 실행 중인 상태에서 onPointerDown 이벤트가 발생하거나, 애니메이션이 종료된 상태에서 onPointerUp 이벤트가 발생하면 중복 호출로 판단하면 될 것 같아요.

 return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerDown: (_) { 
          // 애니메이션 실행 중이라면 중복 호출로 판단하여 리턴
           if (controller.isAnimating) return;
              
          _controller.forward();   
 .       } 
        onPointerUp: (_) async{   
         // 애니메이션이 종료된 상태라면 중복 호출로 판단하여 리턴
          if(_controller.isDismissed) return;
          
           await _controller.forwrad();
           onTap();
           _controller.reverse();   
 .       } 
        child: TaxRefoundCard();
      ),  
    );  

위 코드에서는 AnimationController의 상태값(isAnimating, isDismissed)을 확인하는 가드문을 추가해 중복 호출 여부를 판단하고, 중복 호출 시 애니메이션 실행이나 onTap 메소드 호출을 방지했습니다.

PointerEvent를 활용한 고유 터치 식별

다만, 이렇게 연속적으로 위젯이 터치되어 실행되는 중복 이벤트 호출은 예외처리할 수 있겠지만, 거의 동시에 여러 위젯이 클릭 되었을 때 발생하는 중복 호출은 여전히 막지 못합니다.

한 화면에서 여러 위젯이 거의 동시에 탭되었는지 판단하는 것은 까다로워 보이지만, Listener 위젯의 터치 제스처 콜백 메소드가 제공하는 PointerEvent 객체를 활용하면 해결의 실마리를 찾을 수 있습니다.

 return Transform.scale(  
      scale: _scaleValue.value,  
      child: Listener(  
        onPointerUp: (PointerUpEvent event)  { 
          /// 애니메이션 실행되고 있는 상태에서 onTapDown이 되었다면 중복호출된 경우 이므로 리턴
           if (controller.isAnimating) return;
              
          _controller.forward();   
 .       } 
 .       onPointerDown: (PointerUpEvent event)  {   
         /// 애니메이션 실행되지 않은 상태라면 onTapUp이 되었다면 중복호출된 경우 이므로 리턴
          if(_controller.isDismissed) return;
          
           await _controller.forwrad();
           onTap();
           _controller.reverse();   
 .       } 
        child: TaxRefoundCard();
      ),  
    );  

Listener의 터치 제스처 콜백 메소드는 PointerEvent라는 객체를 인자로 제공합니다. 이 객체는 터치와 관련된 다양한 정보를 포함하며, 터치 강도(pressure)와 같은 세부 정보까지 확인할 수 있습니다. 여러 정보를 제공하지만 PointerEvent에서 제공하는 pointer값에 해결책이 있습니다.

  onPointerDown: (PointerUpEvent event)  {   
       final int potiner = event.pointer;
       print('onPointerUp pointer : $pointer');
  }
  onPointerUp: (PointerUpEvent event)  {   
       final int potiner = event.pointer;
       print('onPointerDown : $pointer');
  }

터치 이벤트가 발생할 때마다 새로운 정수값의 pointer가 생성되며, 이를 통해 각 이벤트를 고유하게 식별하는데 사용됩니다.

[log] onPointerUp pointer : 0
[log] onPointerDown pointer : 0
[log] onPointerUp pointer : 1
[log] onPointerDown pointer : 1
[log] onPointerUp pointer : 2
[log] onPointerDown pointer : 2

실제로 여러번 터치를하여 porinter값을 출력해 보면 onPointerUp, onPointerDown을 한 쌍으로 매번 새로운 정수형 형태의 값이 생성되는 걸 확인할 수 있죠.

그러면 이제 이 pointer라는 고유한 값을기 반으로 아래와 같은 예외 처리 작업을 해볼 수 있겠습니다.

int? currentPointer; // <--전역 (top-level) 수준에서 nullable한 변수를 선언


Listener(  
  onPointerDown: (_) {  
    if(currentPointer != null) return; // <-- pointer값이 null이 아니라면 종료  
    _controller.forward();  
    currentPointer = event;   
  },  
  onPointerUp: (_) {  
    if(currentPointer != event.pointer) return; // <-- pointer값이 다르다면 종료  
    await _controller.forwrad();  
        . onTap();  
    currentPointer = null; // <-- pointer 값을 null로 초기화  
    _controller.reverse();  
  },  
  child: FilledButton(  
    onPressed: () {  
      ...  
    },  
    child: Text(  
      '환급 받기',  
    ),  
  ),  
),

nullable한 currentPointer라는 전역 변수를 생성한 뒤, onTapDown이 호출되면 currentPointer 값을 현재 고유한 pointer 값으로 갱신하고, onTapUp이 호출되면 다시 null로 초기화합니다.

이 로직을 통해, onTapDown 이벤트 발생 시 currentPointer 값이 null이 아니라면, 현재 화면에서 다른 영역에서 터치 이벤트가 실행 중인 것으로 간주하고 메소드를 종료 해줍니다. 또한, onTapUp에서 전달된 pointer 값이 전역 변수 currentPointer와 일치하지 않으면, 다른 터치 이벤트가 실행 중인 것으로 판단해, onTap 이벤트를 실행하기 전에 메소드를 종료하도록 설계하였습니다.

이제 연속적인 탭을 하거나 여러 영역이 탭되는 경우 onTap 이벤트가 여러 번 실행되는 것을 currentPointer라는 nullable 정수형 flag값을 기반으로 중복 호출을 방지할 수 있습니다.

그리고 지금까지 작성한 터치 인터렉션 로직들을 BounceTapper라는 커스텀 위젯으로 모듈화해 준다면,

BounceTapper(
  onTap: () {
    ...
  },
  child: RefundCard(),
);

BounceTapper(
  child: FilledButton(
    onPressed: () {
      ...
    },
    child: Text('환급 받기'),
  ),
);

이렇게 어떤 위젯에든 간단하고 유연하게 터치 인터렉션을 적용할 수 있습니다.


4. 터치 영역을 벗어났을 때 인터렉션 해제하기

GestureDetector, FilledButton, Inkwell 같이 터치 제스쳐을 지원하는 위젯들은 터치가 된 상태에서 터치 영역 밖으로 이동하면 터치가 해제되면 별도의 onTap 이벤트를 실행시키지 않습니다. 이는 내부적으로 터치 영역을 벗어났는지 확인하고, 터치를 해제하는 로직이 구현되어 있기 때문인데요.

아쉽게도 현재 사용 중인 Listener 위젯은 Raw 수준의 터치 제스처만 인식하기 때문에 터치 영역을 벗어났을 때 이를 해제하는 로직이 기본적으로 제공되지 않지만, 직접 만들 수 있는 방법은 있습니다.

Listener 위젯은 onPointerDown, onPointerUp 제스쳐 뿐만 아니라 화면에서 손가락이 터치된 상태로 움직일 때마다 onPointerMove 콜백 메서드를 실행합니다.

onPointerMove 메서드는 앞서 언급한 제스처 이벤트들처럼 PointerEvent 객체를 제공하며, 해당 객체의 localPosition 속성을 통해 현재 터치가 발생한 좌표를 확인할 수 있습니다.

이 좌표를 활용하여 터치가 지정된 영역을 벗어났는지 판별하려면, 위젯의 크기(너비와 높이)를 기준으로 터치 좌표가 경계를 넘어섰는지 확인하면 될 것 같습니다.

/// 특정 위치가 터치 영역 내에 있는지 확인하는 메서드
bool isWithinBounds({required Offset position, required Size touchAreaSize}) {  
  return !(position.dx <= 0 ||  
      position.dx >= touchAreaSize.width ||  
      position.dy <= 0 ||  
      position.dy >= touchAreaSize.height);  
}

// 터치 영역을 식별하기 위한 GlobalKey
final GlobalKey _touchAreaKey = GlobalKey();

...

return Listener(
  // 현재 위젯에 GlobalKey를 연결
  key: _touchAreaKey,  
  // 터치가 움직일 때마다 호출되는 콜백
  onPointerMove: (PointerEvent event) {  
    // 현재 터치 위치가 지정된 영역을 벗어났는지 확인
    if (!isWithinBounds(  
      position: event.localPosition, 
      touchAreaSize: _touchAreaKey.currentContext?.size ?? Size.zero, 
    )) {  
      // 터치가 영역을 벗어난 경우
      if (_controller.isCompleted) {  // 애니메이션이 완료된 상태라면
        await _controller.reverse();  
        currentPointer = null;  
      }  
    }
  }
);

Listener 위젯에 GlobalKey를 할당해 현재 위젯의 크기(높이와 너비)를 가져온 뒤, 이를 바탕으로 터치 좌표가 영역을 벗어났는지 확인하는 isWithinBounds 메서드를 작성했습니다.

이 메서드는 터치 좌표가 영역 내부에 있다면 true, 그렇지 않으면 false를 반환하며, 이를 통해 터치 인터렉션을 해제할지 결정할 수 있습니다.


5. 스크롤이 되었을 때 인터렉션을 해제

이제 꽤 쓸만한 터치 인터렉션을 모듈을 구현했지만, 조금 더 욕심내어 섬세한 인터렉션을 로직들을 추가해 보려고 합니다.

토스앱을 유심히 살펴보면 특정 영역이 터치되어 축소 애니메이션이 일어난 상태에서 스크롤이 되면 현재 축소된scale 애니메이션을 즉시 해제되면서 동시에 별도의 터치 이벤트(onTap)도 실행시키지 않는다는 것을 확인하실 수 있습니다..이 동작을 기존 모듈에 적용해 보려고 합니다.


final scrollController = ScrollController();  
  
scrollController.addListener(() async {  
   if (currentPoint == null) return;  

    /// 스크롤 제스쳐가 감지되었고 현재 터치 애니메이션이 실행 중이라면
    /// 애니메이션을 해제하고 터치이벤트가 발생하지 않도록 값을 설정
      await _controller.reverse();  
      currentPoint = null;
});  
  
  
return Listener(..)

간단히 BounceTapper 모듈에 scrollController를 전달해 주고 모듈 내부에서 전달받은 ScrollController로부터 터치 제스쳐를 감지할 수 있는 addListener 등록해준 뒤, 스크롤이 되면 addListener 콜백 메소드가 실행되기 때문에 내부에 애니메이션이 진행 중이라면 애니메이션을 해제(reverse)하고 onTap 이벤트또한 실행되지 않도록 currentPoint를 null로 초기화 해주면 되겠죠.

잘 작동은 하겠지만, 이렇게 ScrollController를 전달하여 addListener 로직을 BouncTapper 모듈 내부 실행해 주는 방식으로 설계한다면 한 가지 번거로운 점이 발생합니다.

SingleChildScrollView(  
    controller: scrollController,  
    child: Column(  
      mainAxisAlignment: MainAxisAlignment.center,  
      children: [  
        BounceTapper(  
          scrollController: scrollController,  
          child: WidgetA(),  
        ),  
        BounceTapper(  
          scrollController: scrollController,  
          child: WidgetB(),  
        ),  
        BounceTapper(  
          scrollController: scrollController,  
        ),  
        BounceTapper(  
          scrollController: scrollController,  
        ),  
      ],  
    ),  
  ),  

Scroll을 지원하는 위젯 내부에 BounceTapper 위젯이 많아질수록 scrollController를 각 위젯마다 매번 전달해야 한다는 것이죠. 위젯 트리의 깊이가 깊어질수록 관리가 더 까다로워지고 코드도 점점 복잡해질 것입니다.

ScrollController를 일일이 전달하지 않고 접근할 방법이 없을까요? Flutter에서 제공하는 Scroll.maybeOf 메소드를 사용하면 이를 손쉽게 해결할 수 있습니다.

Scroll.maybeOf는 전달받은 BuildContext에서 가장 가까운 조상(ancestor) ScrollController를 반환해 줍니다. 이를 통해 ScrollControllerBounceTapper 모듈에 명시적으로 전달할 필요가 없어집니다.

final ScrollController? scrollController = Scrollable.maybeOf(context)?.widget.controller

참고로, SingleChildScrollViewListView처럼 스크롤이 가능한 위젯은 기본적으로 ScrollController를 생성하므로, 개발자가 따로 할당하지 않아도 됩니다.

  
void initState() {  
  ....
  
  WidgetsBinding.instance.addPostFrameCallback((_) {  
    final ScrollController? scrollController =  
        Scrollable.maybeOf(context)?.widget.controller;  
    scrollController?.addListener(() {  
      if (!animationController.isForwardOrCompleted ||  
          currentPoint == null) return;  

    /// 스크롤 제스쳐가 감지되었고 현재 터치 애니메이션이 실행중이라면
    /// 애니메이션을 해제하고 터치이벤트가 발생하지 않도록 값을 설정
      await _controller.reverse();  
      currentPoint = null;
    });  
  });  
}

이제 BounceTapper 모듈의 initState 메소드에서 위 코드와 같이 ScrollController를 리슨하는 로직을 추가합니다.

Scrollable.maybeOf(context)를 통해 ScrollController에 접근하려면 context가 완전히 초기화된 이후여야 하므로, WidgetsBinding.instance.addPostFrameCallback을 사용해 위젯 트리가 생성된 다음에 해당 로직이 실행되도록 합니다.

참고: 상위 위젯에 스크롤 위젯이 없는 경우 null을 반환하므로, 이 경우에는 addListener가 등록되지 않습니다.


6. 터치되었을 때 하이라이트 되는 효과

자, 거의 다 왔습니다. 마지막으로 토스의 터치 인터렉션을 하나 더 참고하여 적용해 보죠.
토스 앱에서는 터치 영역이 활성화되면 해당 영역 위에 오버레이되어 하이라이트 효과가 존재합니다. 이러한 효과는 인터페이스의 시각적 피드백을 제공하여 사용자의 경험을 향상시킬 수 있는 하나의 요소가 될 수 있기에 꽤 중요한 부분이라고 생각됩니다.

BounceTapper에 동일한 하이라이트 효과를 적용하기 위해 Stack 레이아웃을 사용해 구현해보겠습니다.

AnimatedBuilder(  
  animation: _animation,  
  child: widget.child,  
  builder: (context, child) {  
    return Transform.scale(  
      alignment: Alignment.center,  
      scale: _animation.value : 1.0,  
      child: Stack(  
        clipBehavior: Clip.none,  
        children: [  
          // 터치 애니메이션을 적용하려고 하는데 위젯
          child!,  
          /// 하아라이트 박스 
          Positioned.fill(  
            child: ClipRRect(  
              borderRadius: targetRadius,
              child: IgnorePointer(  
                child: Builder(  
                  builder: (context) {  
                    const shrinkScaleFactor = 0.965;  
                    final opacity = _animation.value <= shrinkScaleFactor  
                        ? 1.0  
                        : (1.0 - _animation.value) /  
                            (1.0 - shrinkScaleFactor);  
  
                    return Opacity(  
                      opacity: opacity,  
                      child: ColoredBox(  
                        color: widget.highlightColor,  
                      ),  
                    );  
                  },  
                ),  
              ),  
            ),  
          ),  

        ],  
      ),  
    );  
  },  
),

Stack 내부에 하이라이트 박스를 Positioned로 감싸 위젯(child) 위에 오버레이 형태로 배치했습니다.
이 하이라이트 박스는 opacity 값을 조정하여 노출 여부를 제어합니다.

해당 코드에서는 AnimationController의 값(value)에 따라 opacity를 설정합니다.
애니메이션이 진행될수록 축소(value0.965로 수렴)될 때는 opacity1.0에 가까워지고,
확장(value1.0으로 수렴)될 때는 opacity0.0으로 감소하여 하이라이트 효과가 사라지게 설정했습니다.

또한, 하이라이트 박스를 IgnorePointer로 감싸 터치 이벤트를 방해하지 않도록 하고, ClipRRect로 감싸 borderRadius 를 지정할 수 있도록 설계했습니다.

자동으로 borderRadius 설정하기

하이라이트 효과가 잘 작동하지만, 한 가지 추가로 고려해야 할 점이 있습니다.

BounceTapper(
    tagetBorderRadius : BorderRadius.radius(8)
	child : FilledButton(
	  child : Text('환급받기')
	   )
),

터치 인터랙션이 적용된 위젯에 borderRadius 가 설정되어 있다면, 위 코드처럼 해당 값을 BounceTapper 모듈에도 반드시 전달해야 합니다.

borderRadius 값을 누락하면 위 그림처럼 하이라이트 영역이 터치 위젯의 경계를 벗어나 어색해 보일 수 있습니다.
하지만 매번 값을 수동으로 전달하는 것은 번거롭습니다. 위젯의 borderRadius를 자동으로 추출해 적용할 수 있다면 훨씬 간편해지겠죠?

앞서 Scrollable.maybeOf(context) 메소드를 통해 위젯 트리의 상위에서 ScrollController를 자동으로 추출했던 것처럼, 이번에도 비슷한 접근 방식을 활용해 보면 될 것 같습니다. BuildContext를 기반으로 하위 위젯 트리를 순회하면서 borderRadius 값을 추출하는 것이죠. 아래와 같이 코드를 작성해 보았습니다.

/// 주어진 context에서 가장 가까운 borderRadius 값을 추출하는 메소드
BorderRadiusGeometry? getChildBorderCloseBorderRadius(BuildContext context) {  
  try {  
    BorderRadiusGeometry? closestBorderRadius;  
  
    void inspectElement(Element element) {  
      final renderObject = element.renderObject;  
      if (renderObject is RenderBox) {  
        final renderInfo = _getRenderInfoFromRenderObject(renderObject);  
        if (context.size == renderInfo.size &&  
            renderInfo.borderRadius != null &&  
            renderInfo.borderRadius != BorderRadius.zero) {  
          closestBorderRadius = renderInfo.borderRadius;  
          return;  
        }  
      }  
      // 자식 요소를 순회하여 위의 조건을 충족하는 borderRadius가 있는지 확인
      element.visitChildren((childElement) {  
        inspectElement(childElement);  
        if (closestBorderRadius != null) return;  
      });  
    }  
  
    final rootElement = context as Element;  
    inspectElement(rootElement);  
  
    return closestBorderRadius;  
  } catch (e) {  
     log('대상 위젯의 borderRadius를 추출하는 동안 오류가 발생했습니다. 예상치 못한 오류나 Flutter 버전 호환성 문제일 수 있습니다: $e');
    return null;  
  }  
}  

/// 다양한 유형의 RenderBox에서 borderRadius를 추출하는 메소드
({Size size, BorderRadiusGeometry? borderRadius})  
    _getRenderInfoFromRenderObject(RenderBox renderObject) {  
  if (renderObject is RenderClipRRect) {  
    return (size: renderObject.size, borderRadius: renderObject.borderRadius);  
  }  
  if (renderObject is RenderPhysicalModel) {  
    return (size: renderObject.size, borderRadius: renderObject.borderRadius);  
  }  
  if (renderObject is RenderDecoratedBox) {  
    final decoration = renderObject.decoration;  
    if (decoration is BoxDecoration) {  
      return (size: renderObject.size, borderRadius: decoration.borderRadius);  
    } else if (decoration is ShapeDecoration) {  
      final shape = decoration.shape;  
      if (shape is RoundedRectangleBorder) {  
        return (size: renderObject.size, borderRadius: shape.borderRadius);  
      }  
    }  
  }  
  if (renderObject is RenderPhysicalShape) {  
    final CustomClipper<Path>? clipper = renderObject.clipper;  
    if (clipper is ShapeBorderClipper) {  
      final shape = clipper.shape;  
      if (shape is RoundedRectangleBorder) {  
        return (size: renderObject.size, borderRadius: shape.borderRadius);  
      }  
    }  
  }  
  // borderRadius가 없는 경우 null 반환
  return (size: renderObject.size, borderRadius: null);  
}

코드가 꽤 복잡해 보이지만 원리는 간단합니다. BuildContext를 순회하며 특정 RenderBox에서 borderRadius를 추출하는 것이죠. 예외적인 상황에서도 오류가 발생하거나 잘못된 borderRadius 값을 반환하지 않도록 여러 안정장치를 두었으며, borderRadius 값을 반환하거나, 없을 경우 null을 반환합니다.

BorderRadiusGeometry? targetRadius;


initState(){
WidgetsBinding.instance.addPostFrameCallback((_) {  
  targetRadius = getChildBorderCloseBorderRadius(context);  
}

Scrollable.maybeOf(context)와 동일하게, getChildBorderCloseBorderRadius 메소드 context을 전달받아 필요한 작업을 처리하기 때문에 위젯 트리가 완전히 초기화된 후에 context 에 접근할 수 있도록 보장해 주어야 합니다. 따라서WidgetsBinding.instance.addPostFrameCallback을 사용해 borderRadius를 추출하는 메소드를 실행하도록 설정하였습니다.

마지막으로 getChildBorderCloseBorderRadius메소드를 통해 추출된 값을 ClipRRect 위젯에 전달하면, 매번 수동으로 값을 설정할 필요 없이 자동으로 적절한 borderRadius 가 적용되게 됩니다.


삼쩜삼앱에 적용하기

이제 지금까지 구현된 BounceTapper을 모듈을 삼쩜삼앱에 적용해볼 차례입니다.

BounceTapper(  
  onTap: (){  
    ...  
  },  
  child: const _TaxRefundStatusCard(),  
),

어떠한 위젯이든 터치 인터렉션을 적용하고 싶은 위젯에 BounceTapper 위젯을 감싸주기만 하면 됩니다.

BounceTapper(  
  child: FilledButton(  
    onPressed: () async {  
      Navigator.of(context).push(  
        MaterialPageRoute(  
          builder: (context) => const DetailPage(),  
        ),  
      );  
    },  
    child: const Text('환급 받기'),  
  ),  
),

특히, 앞서 Listener위젯을 사용하여 터치 이벤트를 리슨하기 때문에 FilledButton과 같은 터치 제스쳐와 충돌하지 않기에 기존 위젯의 onTap 또는 onPress 이벤트를 수정하지 않아도 되어 훨씬 간편하기도 합니다.

삼쩜삼앱의 홈 영역을 클론하여 터치 인터렉션 로직을 적용하였는데요. 구현 영상을 아래 유튜브 링크를 참고해주세요.

예제 코드가 궁금하다면 아래 깃허브 레포지토리를 참고해 주세요.
👉 https://github.com/Xim-ya/three_point_three

서론에 잠깐 언급 드렸지만, 혹시 여러분의 프로젝트에 터치인터렉션을 적용하고 싶으면 최근에 제가 배포한 패키지를 사용해 보시길 권장드립니다. 글에서 다루지 못한 더 세밀한 인터렉션 로직들이 여럿 적용되어 있습니다.

👉 bounce_tapper : https://pub.dev/packages/bounce_tapper


마무리하면서

이번 글에서는 여러 빅테크앱에 적용되어 있는 축소 / 확대 터치 인터렉션을 적용하는 방법에 대해 알아보았습니다. 사실 이번에 bounce_tapper 패키지를 개발하면서 초반에는 정말 간단한 작업이라고 생각했지만, 세밀한 인터렉션을 하나하나 고려해야 하고 쉽게 사용할 수 있는 형태로 모듈화하는 방법을 구상하다보니 생각보다 공수를 많이 들이게 되었네요.

긴 글 읽어주셔서 감사하며, 다음에는 조금 더 유익한 글로 돌아오겠습니다.
감사합니다 :)

profile
https://medium.com/@ximya

4개의 댓글

comment-user-thumbnail
4일 전

감사합니다. maybeOf() 랑 context 내 위젯 순회 등 많은 걸 배웠습니다 👍
사용하기 편하도록 패키지까지 배포해주셔서 너무 좋아요!! 😊

1개의 답글
comment-user-thumbnail
4일 전

개인적으로 플러터에서 가장 까다로운 부분이 섬세한 애니메이션이라고 생각하는데, 정말 대단하신 것 같아요!

1개의 답글