본문 바로가기

파이어몽키

[모바일앱예제] 유튜브, 페이스북에서 사용되는 Swipe 메뉴 샘플

 목록으로 돌아가기

시작하기에 앞서


이 글은 처음부터 기능을 따라하며 만드는 것 보다는 제공되는 예제코드를 참고해 기능을 익히도록 설명되어 있습니다. 


예제를 통해 기능을 완전히 익히신 후 새로운 프로젝트에 기능을 떼어 붙이며 본인 것으로 만들면 더 좋습니다.

만약, 내용이 어려운 경우 해당 프로젝트를 다른 이름으로 저장 후 Frame 부분에 기능을 추가해 앱을 개발해도 무방합니다.
그럼 시작합니다.

이번 글에서는 스와이프(Swipe) 메뉴를 파이어몽키에서 구현하는 방법을 설명합니다.

Swipe menu는 유투브, 페이스북등의 앱에서 많이 사용하는 손가락으로 끌어서 메뉴를 호출하는 방식입니다.


이번 글은 아래 글의 연장이므로 아래 글을 먼저 보시기 바랍니다.

(링크가 만들어지면 링크를 추가하겠습니다.)


최종 구현되는 앱의 모습은 아래의 동영상과 같습니다.


소스코드

Gesture 이벤트

Swipe menu 구현의 핵심은 폼의 Gesture 이벤트입니다. 메인폼의 Gesture 이벤트 코드를 먼저 보고 설명합니다.

procedure TForm1.FormGesture(Sender: TObject;
  const EventInfo: TGestureEventInfo; var Handled: Boolean);
var
  MovePos: TPointF;
begin
  if EventInfo.GestureID = igiPan then
  begin
    // Touch event 시작
    if TInteractiveGestureFlag.gfBegin in EventInfo.Flags then
    begin
      FSwipeData.Direction := TSwipeDirection.None;
      FSwipeData.MouseDownPos := EventInfo.Location;
    end
    // Touch event 이동
    else if EventInfo.Flags = [] then
    begin
      if FSwipeData.Direction = TSwipeDirection.None then
      begin
        MovePos := EventInfo.Location - FSwipeData.MouseDownPos;

        // Android의 경우 첫번째 EventInfo.Location과 FSwipeData.MouseDownPos가 같음
        if MovePos = PointF(0, 0) then
          Exit;
        // SWIPE_MOVE_MINVALUE(10)이상 움직인 경우 시작
        if FSwipeData.StartPos.Distance(EventInfo.Location) > SWIPE_MOVE_MINVALUE then
        begin
          if (Abs(MovePos.X) > Abs(MovePos.Y) * 2) then
          begin
            FSwipeData.Direction := TSwipeDirection.Horizontal;
            DoSwipeBegin(FSwipeData.MouseDownPos);
          end
          else
          begin
            FSwipeData.Direction := TSwipeDirection.Etc;
          end;
        end;
      end;

      if FSwipeData.Direction = TSwipeDirection.Horizontal then
        DoSwipe(EventInfo.Location);
    end
    // Touch event 끝(손가락을 뗌)
    else if TInteractiveGestureFlag.gfEnd in EventInfo.Flags then
    begin
      if FSwipeData.Direction = TSwipeDirection.Horizontal then
        DoSwipeEnd(EventInfo.Location);
      FSwipeData.Direction := TSwipeDirection.None;
    end;
  end;

위 소스코드에서 아래의 내용을 알아야 합니다.

  • 제스처 이벤트 정보(EventInfo)
  • 터치 이벤트의 시작과 끝
  • Swipe 이벤트의 시작 조건

❑ 제스처 이벤트 정보(EventInfo)

Gesture 이벤트가 발생 시 제스처 이벤트의 정보가 EventInfo 파라메터에 담겨 전달됩니다. 

EventInfo는 TGestureEventInfo 타입의 구조체(record)이며 다음과 같은 정보가 전달됩니다.

  • GestureID : 제스처의 종류(Pan, Rotate, TwoFingerTap, PressAndTap, LongTap, DoubleTap)
  • Location : 제스처 이벤트 발생 좌표(TPointF)
  • Flags : 제스처 상태(TInteractiveGestureFlag) 집합
  • 기타 Angle, InertiaVector, Distance, TapLocation
  • 제스처 이벤트 정보의 자세한 정보는 TGestureEventInfo 도움말을 통해 더 자세히 알아보세요.

소스코드에서 사용되는 속성은 GestureID, Location, Flags 3가지입니다.

❑ 터치 이벤트의 시작과 끝

Swipe menu는 제스처 이벤트 중 끄는방식의 Pan 제스처를 이용합니다.(GestureID = igiPan)

폼에서 Pan 제스처 사용을 위해서는 폼의 속성에서 Touch.InterctiveGestures.Pan 속성을 True로 설정해야지 Pan 동작 시 OnGesture 이벤트가 발생합니다.



Pan(끄는) 이벤트의 시작(손가락을 붙일 때)과 끝(손가락을 뗄 때)은 EventInfo.Flags 항목을 통해 판단할 수 있습니다.


소스코드를 참고해 보면 아래와 같이 터치 시작, 터치 이동, 터치 종료를 판단할 수 있습니다.

  • 터치 시작 : if TInteractiveGestureFlag.gfBegin in EventInfo.Flags then
  • 터치 이동 : if EventInfo.Flags = [] then
  • 터치 종료 : if TInteractiveGestureFlag.gfEnd in EventInfo.Flags then

❑ Swipe 이벤트의 시작조건

Swipe 이벤트의 시작은 터치 시작 이벤트와 별도로 진행됩니다. 왜냐하면, Swipe 이벤트의 경우 터치 이후 좌/우로 끄는 동작 이후 발생하기 때문입니다. 

Swipe 이벤트는 터치 시작(gfBegin) 이벤트 이후 터치 이동 시 아래 2가지 조건을 만족하는 경우 Swipe 시작이라 판단합니다.

조건1) 이동거리가 10(pixel) 이상인 경우

Tab(Click), LongTab 등의 이벤트와 구분하기 위해 10 pixel 이상의 움직임이 있을 경우 Swipe 이벤트를 발생합니다.

다음과 같이 TPointF의 거리로 이동 거리를 판단할 수 있습니다. SWIPE_MOVE_MINVALUE는 상수로 10이 선언되어 있습니다.

if FSwipeData.StartPos.Distance(EventInfo.Location) > SWIPE_MOVE_MINVALUE then

조건2) 좌우 이동 거리가 상하 이동 거리의 2배 즉 좌우로 많이 이동 시 

Swipe는 좌우 또는 상하로 이동하는 경우만 발생합니다.(예제에는 좌우로 Swipe 하는 기능만 구현되어 있습니다.)

좌우로 이동되었다는 판단은 아래의 코드와 같이 X축 이동이 Y축 이동보다 2배 이상. 즉, 좌우로 많이 움지였다는 판단이 있으면 수평으로 Swipe를 한다고 판단합니다.(이 조건은 상하의 폭을 고정하도록 변경도 가능합니다.)

if (Abs(MovePos.X) > Abs(MovePos.Y) * 2) then

이후 터치 이동 이벤트 발생 시 수평으로 Swipe를 사용(FSwipeData.Direction = TSwipeDirection.Horizontal)한다면 Swipe 이동 이벤트(DoSwipe)를 발생합니다.


주의

Swipe 기능은 폼의 Pan 제스처를 이용하기 때문에 폼위의 컨트롤에서 Pan 제스처를 사용(Touch.InterctiveGestures.Pan = True)할 경우 Swipe 기능이 작동되지 않습니다. 일례로 ListBox는 기본값으로 Pan 제스처 사용으로 되어 있어 ListBox 위에서 Swipe 기능을 구현하려면 Pan Gesture 사용하지 않도록 속성을 변경(Touch.InterctiveGestures.Pan := False) 해야 합니다.


반대로, ScrollBox를 사용할 경우 ScrollBox에서 Pan 제스처를 허용하면, ScrollBox 이외의 영역에서 Swipe 기능을 이용할 수 있습니다.


왜? Gesture이벤트를 이용할까? MouseDown, MouseMove, MouseUp 이벤트를 이용해 구현해되 되지 않을까?

Gesture 이벤트를 이용해 Swipe기능을 구현한 이유는  MouseDown, MouseMove, MouseUp이벤트의 경우 폼위에 컨트롤이 있을 경우 해당 컨트롤에서 마우스 이벤트를 가져갑니다.

예를 들어 ListView가 폼에 가득차게 구성한 경우 터치 후 드래그 하게되면 폼이 아닌, ListView에서 마우스 이벤트가 발생하게 됩니다.

하지만, Gesture 이벤트를 이용하면 컨트롤이 Pan 제스처를 사용하지 않는 다면(Touch.InteractiveGesture.Pan := False) 폼위에 컨트롤들이 있어도 폼은 Gesture이벤트를 사용할 수 있어 Swipe 메뉴와 같은 앱 전체적으로 터치 이벤트를 사용이 필요한 경우 사용할 수 있습니다.

그리고 Pan 제스처를 사용하지 않아도 컨트롤들의 터치(클릭), 스크롤등에 영향을 주지 않습니다.


사이드바 메뉴와 Swipe 기능 연결하기

FormGesture 이벤트에서 터치이벤트에 따라 3가지(DoSwipeBegin, DoSwipeMove, DoSwipeEnd)의 Swipe 메소드를 호출합니다. 

❑ DoSwipeBegin(Swipe 시작)

procedure TForm1.DoSwipeBegin(const P: TPointF);
var
  SwipeEvent: ISupportSwipeEvent;
  Handled: Boolean;
begin
  Handled := False;
  if Assigned(FCurrentMenu) and Supports(FCurrentMenu.View, ISupportSwipeEvent, SwipeEvent) then
    SwipeEvent.SwipeBegin(P, Handled);

  if Handled then
    Exit;

  FSwipeBeginStopWatch := TStopWatch.StartNew;
  FMenuHelperDown := False;
end;

앞에서 설명한바와 같이 터치 무브 이벤트에서 좌우로 일정거리를 이동한 경우 DoSwipeBegin 이벤트가 발생합니다.

코드의 아래쪽의 TStopWatch를 사용하는 코드는 Swipe를 빠르게 이동할 경우 예외를 두기 위해 시작시간을 기록하는 코드입니다. DoSwipeEnd 메소드에서 터치 시작 후 뗀 시간과 이동거리를 이용해 사이드바를 열고 닫는 것을 판단합니다.


ISupportSwipeEvent와 관련된 코드는 프레임과 연동하기위한 코드입니다. 이글의 뒷쪽에서 다시 설명합니다.

❑ DoSwipeMove(Swipe 이동)

procedure TForm1.DoSwipeMove(const P: TPointF);
var
  MovePos: TPointF;

  SwipeEvent: ISupportSwipeEvent;
  Handled: Boolean;
begin
  Handled := False;
  if Assigned(FCurrentMenu) and Supports(FCurrentMenu.View, ISupportSwipeEvent, SwipeEvent) then
    SwipeEvent.SwipeMove(P, Handled);

  if Handled then
    Exit;

  MovePos := P - FSwipeData.StartPos;
  SetMenuPosition(MovePos.X);
end;

procedure TForm1.SetMenuPosition(const Value: Single);
begin
  if ShowMenu then
  begin
    if Value < 0 then // 왼쪽으로
      lytSidebar.Position.X := Max(Value, -lytSidebar.Width);
  end
  else
  begin
    if Value > 0 then
      lytSidebar.Position.X := Min(-lytSidebar.Width + Value, 0);
  end;

  lytMenuHelper.Visible := True;
  lytMenuHelper.Opacity := ((lytSidebar.Width + lytSidebar.Position.X) / lytSidebar.Width) * MENUHELPER_OPACITY;
end;

Swipe 이동시에는 사이드바의 위치를 변경하는 작업을 합니다. SetMenuPosition의 Value 인자에는 처음 터치한 좌표와 현재 터치된 좌표간의 수평거리(X값)가 전달됩니다.


사이드바 위치는 이미 보여진 상태에서는 닫히는 방향으로, 감춰진 상태에서는 보여지는 방향으로 처리됩니다.

참고로 사이드바가 보여질때 X 좌표가 0이고 감춰져 있을때는 음수 값입니다.

TIP. Max, Min 함수 소개

Max(A, B) 함수는 A와 B값 중 큰 값을 반환하는 수학함수입니다. Min 함수는 작은 값을 반환합니다.  System.Math 유닛을 추가(uses) 해야 합니다.


사이드바 위치를 조정하고 사이드바 이외의 배경의 투명도(Opacity)를 조정해 사이드바가 많이 열릴 수록 진하게 처리합니다.

❑ DoSwipeEnd(Swipe 종료)

procedure TForm1.DoSwipeEnd(const P: TPointF);
var
  MovePos: TPointF;
  SwipeEvent: ISupportSwipeEvent;
  Handled: Boolean;
begin
  Handled := False;
  if Assigned(FCurrentMenu) and Supports(FCurrentMenu.View, ISupportSwipeEvent, SwipeEvent) then
    SwipeEvent.SwipeEnd(P, Handled);

  if Handled then
    Exit;

  MovePos := P - FSwipeData.StartPos;
  SetMenuPosition(MovePos.X);

//  Log.d('DoSwipeEnd : %f > %f(SW: %d)', [lytSidebar.Width + lytSidebar.Position.X, Self.Width * 0.4, FSwipeBeginStopWatch.ElapsedMilliseconds]);
  // 짧게 Swipe하는 경우 방향만 맞으면 메뉴전환
  if FSwipeBeginStopWatch.ElapsedMilliseconds < 300 then
  begin
    if ShowMenu then
      ShowMenu := (MovePos.X > -50)
    else
      ShowMenu := (MovePos.X > 50);
  end
  // 길게 Swipe하는 경우 특정위치 이상 메뉴 이동 시 전환
  else
    ShowMenu := (lytSidebar.Width + lytSidebar.Position.X) >= lytSidebar.Width * 0.5;
end;

Swipe가 끝나는 시점에서는 Swipe 시작과 이동한 내용에 따라 결과적으로 사이드바를 열지와 닫을지를 결정해야 합니다.

Swipe 시작 시 TStopWatch로 기록한 시간을 이용해 빠르게 Swipe 완료된 경우와 천천히 완료한 경우로 분개됩니다.

빠르게 Swipe를 완료(300 ms 이하)한 경우에는  50 pixel 이상만 움직여도 사이드바를 열고, 닫도록 합니다.

길게 Swipe를 완료한 경우는 화면의 반이상을 열거나 닫을 경우 처리되도록 합니다.

기타 내용

❑ 다른 컨트롤들 위에서 Swipe 하기

Pan 제스처 예외 사항

앞에서 설명드렸지만 Swipe는 메인 폼의 Pan 제스처를 이용합니다. 메인 폼에서 Pan 제스처를 이용하기 위해서는 다른 컨트롤들이 Pan 제스처를 사용하지 않아야 합니다.

하지만, Pan 제스처를 사용하지 않도록 설정(Touch.InteractiveGesture.Pan := False)해도 일부 리스트(ListView, ListBox)에서 이슈가 있습니다.

ListView와 ListBox는 Siwpe하기 위해 리스트에서 터치 후 좌우로 움직일 때에 상하로도 움직이게 되는데 이때 리스트의 스크롤이 동작합니다. 기능에 완성도를 높이기 위해 Swipe 중에는 리스트의 동작을 멈추는 예외코드가 필요하고 ListView와 ListBox에는 Swipe 시작과 종료시점에 다음 코드를 사용해 스크롤을 제한할 수 있습니다.


ListBox의 경우 Swipe 시작 시 HitTest 속성을 False로 종료 시 True로 변경 해 스크롤을 제한할 수 있습니다.

ListView의 경우 Swipe 시작 시 Enabled 속성을 False로 종료 시 True로 변경 해 스크롤을 제한할 수 있습니다.

(참고로 ListBox의 경우 마우스 기반으로 스크롤되므로 HitTest를 ListView의 경우 Gesture를 기반으로 스크롤 되므로 Enabled로 스크롤을 제한합니다.)


이 예제에서는 ListBox와 ListView가 다른 프레임에서 사용하고 있기 때문에  ISupportSwipeEvent 인터페이스를 선언하고 해당 프레임에서 상속받아 처리하도록 구성했습니다.(뒷쪽에서 다시 설명합니다.)


TIP. HitTest 속성

HitTest 속성은 마우스 이벤트를 컨트롤이 사용할지를 선택하는 속성입니다.

컨트롤의 HitTest 속성을 False로 설정하면 해당 컨트롤에서는 마우스 이벤트 발생시키지 않습니다. 즉, 클릭, 드래그 등의 이벤트가 발생하지 않습니다.

예를들어 ListBox의 경우 MouseDown, MouseMove, MouseUp 이벤트를 기반으로 스크롤 처리를 하기 때문에 HitTest를 False로 선택하면 ListBox의 스크롤 기능이 동작하지 않습니다.


예를 들면, 이미지가 포함된 버튼을 만들고 싶은 경우 버튼 위에 (자식으로)이미지를 위치시키고 이미지의 HitTest를 False로 변경하면 이미지를 클릭해도 이미지의 클릭이벤트를 통과해 버튼의 클릭이벤트가 발생됩니다.

HitTest 속성을 이용하면 이와 같이 이미지를 포함한 버튼을 쉽게 만들 수 있습니다.

프레임과 연동(ISupportSwipeEvent)

이 예제는 화면과 기능을 별도의 파일로 분리하기 위해 프레임을 사용합니다. 

프레임에 Swipe Event를 추가하기 위해 ISupportSwipeEvent를 아래와 같이 추가했습니다.

  ISupportSwipeEvent = interface
  ['{152551D7-6455-4439-BEF8-4345811D5900}']
    procedure SwipeBegin(const AStartPos: TPointF; var Handled: Boolean);
    procedure SwipeMove(const AMovePos: TPointF; var Handled: Boolean);
    procedure SwipeEnd(const AEndPos: TPointF; var Handled: Boolean);
  end;

그리고 Swipe 시작, 이동, 종료 시 프레임에서 작업해야 할 것이 있다면 프레임에서는 아래와 같이 상속해 구현합니다.

SearchFrame에서 ListView를 제어하기 위해 인터페이스를 상속받고 사용하는 코드입니다.

type
  TfmSearch = class(TFrame, IFrameView, ISearchFeature, ISupportSwipeEvent)
  private
    ...
    { ISupportSwipeEvent }
    procedure SwipeBegin(const AStartPos: TPointF; var Handled: Boolean);
    procedure SwipeMove(const AMovePos: TPointF; var Handled: Boolean);
    procedure SwipeEnd(const AEndPos: TPointF; var Handled: Boolean);
  end;

implementation
  ...
procedure TfmSearch.SwipeBegin(const AStartPos: TPointF;
  var Handled: Boolean);
begin
  Handled := False;
  ListView1.Enabled := False;
end;

procedure TfmSearch.SwipeMove(const AMovePos: TPointF; var Handled: Boolean);
begin
  Handled := False;
end;

procedure TfmSearch.SwipeEnd(const AEndPos: TPointF;
  var Handled: Boolean);
begin
  Handled := False;
  ListView1.Enabled := True;
end;

ListView는 Swipe 시 스크롤이 되지 않아야 하기 때문에 Swipe 시작(DoSwipeBegin)에 Enabled를 False로 끝날때 True로 변경합니다.

Handeld 메소드의 경우 프레임에서 Swipe동작을 사용했는지 여부입니다. 사용하 경우(Handled := True) 메인 폼에서는 Swipe와 관련된 사이드바를 이동하는 기능을 하지 않습니다.

예를들어 오른쪽에서 다른 사이드바를 표시하는 작업이 필요하면 프레임에서 구현하고 Handled 값을 True로 변경하는 등의 작업을 구현할 수 있습니다.

마치며

이 기능은 많은 코드가 추가되지는 않았지만, 제스처, EnventInfo, 좌표 계산등의 다양한 내용이 포함되어 어렵게 느껴질 수도 있습니다. 하지만, 모바일 앱 개발 시 앱의 완성도를 한단계 높이기 위해 필요한 터치와 터치의 변화에 따른 좌표계산등을 익힐 수 있는 예제라고 생각합니다.이해가지 않는 내용은 관련된 자료와 샘플을 찾아서 꼭 이해하시길 부탁드립니다.)


예제를 활용하셔서 다양한 기능을 만드시길 바라겠습니다.

어렵거나 궁금한 내용은 댓글로 남겨주시면 글을 보강하는데 소중히 사용하겠습니다.



관련글

참고글