Gotocph israel ferrer camacho cover

Android UI 멋지게 만들기: 예제로 배우는 팁과 노하우

링크에서 슬라이드를 다운로드할 수 있습니다.

소개

저는 Twitter의 안드로이드 개발자, Israel Ferrer Camacho 입니다. Moments를 개발하는데, 전체 화면에서 스크롤 할 수 있고 트윗 리스트를 추가할 수 있습니다. 연대순으로 따를 필요 없이 이야기 형식을 가지죠.

강연의 원어 제목인 “smoke and mirrors”는 진실을 왜곡하거나 가릴 때 쓰는 표현입니다. 주로 눈속임 마술을 하는 마술사에게 사용되곤 합니다. 최근 Google I/O에서 Adam Powell와 Yigit Boyar는 RecyclerView에 대한 강연 첫머리에서 UI 개발자와 마술사의 공통점이 많다고 했죠. 우리가 만드는 애니메이션이나 프레임워크에서 사용하는 구성 요소들을 생각해보자는 취지에서 이 강연의 주제를 결정했습니다.

먼저 안드로이드 프레임워크에서 어떻게 이런 마술을 사용해서 더 완벽하고 매끄러운 멋진 앱을 만들 수 있는지 몇 가지 예를 보여 드리겠습니다. 그런 다음 제가 가장 좋아하는 Android 앱과 구축 경험을 공유한 후 데모를 보여드리겠습니다.

안드로이드 프레임워크

android-ui-tweeter-timeline

위 그림은 트위터의 타임라인입니다. 이론적으로는 항상 타임라인에 무한한 트윗이 있는 것처럼 보이죠. 하지만 문제는 우리가 보여주고 싶은 뷰의 숫자가 무한하더라도 제약이 있다는 겁니다. 동시에 수천 개의 뷰를 인스턴스화할 수는 없으니까요.

실제로는 우리는 다음 단계를 거쳐야 합니다. 한 개, 두 개, 세 개, 네 개의 뷰를 화면에 보여줍니다. 더 이상의 뷰를 보여주거나 인스턴스화할 필요는 없죠. 어떻게 이게 가능할까요? 여기서 RecyclerView라는 구성 요소를 사용합니다. 이름 자체가 힌트가 되죠? 뷰를 재사용합니다.

RecyclerView (02:37)

RecyclerView 내부에서는 흥미로운 작업이 일어납니다. RecyclerView는 선형 LayoutManager를 가지고 있는데 여기서 아이템 뷰들을 측정하고 위치를 지정하는 작업과 더이상 보이지 않는 뷰를 언제 재활용할 것인지를 결정합니다. 다음 위치를 보여줄 때 LayoutManager가 RecyclerView의 getViewForPosition 메서드를 호출합니다. 그러면 RecyclerView가 Adapter에게 뷰의 타입을 묻습니다. RecyclerView에는 여러 타입의 뷰가 있을 수 있으니까요. 예를 들어 여러 다른 헤더를 가질 수도 있고 광고를 게재하기 위한 위치를 원할 수도 있죠.

타입을 얻으면 RecyclerView는 재사용할 수 있는 모든 ViewHolders가 있는 RecyclePool에게서 해당 타입의 ViewHolder를 받습니다. 만약 없다면 Adapter로 가서 새로 만들죠. 있는 경우라면 Adapter에게 건네주고 이 ViewHolder를 새 데이터와 새 위치로 바인딩할 수 있냐고 묻습니다.

이제 받은 정보를 RecyclerView로 다시 돌려주고, LayoutManager가 해당 아이템을 화면에 보여줍니다. RecyclerView가 view를 재활용하는 것에 대해 간단히 설명해 드렸는데, “RecyclerView Ins and Outs”이라는 Google I/O 강연에서 어떻게 아이템을 움직이고 상태 사이에 전환하는지 더 자세히 알 수 있습니다.

공유 요소 전환(Shared Element Transitions)

android-ui-shared

안드로이드 프레임워크의 또다른 예를 트위터 앱으로 보여드리겠습니다. 보시다시피 이미지 목록이 있고 클릭하면 전체 화면으로 전환되는데 이를 공유 요소 전환이라고 합니다.

공유 요소 전환에도 마술이 숨어 있는데, 먼저 어떻게 작동하는지부터 알아보겠습니다. 액티비티는 해상 전환의 상태를 저장하는 액티비티 전환 상태가 있으며, 가장 중요한 부분은 한 화면에서 다른 화면으로 넘어갈 때 필요한 공유 뷰입니다. 예를 들어 onDestroy나 onPause 같은 액티비티 생명 주기가 실행된 후 새 액티비티가 시작되고, 이것이 액티비티 전환 상태를 호출해서 새 전환을 시작하게 합니다. 이때 ActivityTransitionCoordinator가 호출되죠.

ActivityTransitionCoordinator는 기본 클래스로 두 개의 코디네이터가 있습니다. 하나는 들어오는 전환을 처리하고 다른 하나는 나가는 전환을 처리합니다. 이들은 전환 매니저를 가지고 있는데 여기서 전환을 시작하죠. 전환은 기본값을 갖고 있지만 원한다면 테마를 바꿀 수도 있고 커스터마이징도 할 수 있습니다.

공유 요소에서 일어나는 전환의 실제 마술은 어떤 걸까요? 액티비티가 가려지거나 백그라운드에 가고 다른 액티비티가 맨 위에 올라가는 것을 어떻게 가능하게 할까요? 타깃 액티비티를 숨기고, 전환된 위치에서부터 전환되는 뷰의 리스트도 가지고 있어서, 어떤 액티비티에서든 해당 전환을 할 수 있습니다. 프레임워크의 기본 전환은 ViewOverlay를 사용하죠.

ViewOverlay (07:29)

ViewOverlay는 뷰 위에 투명한 레이어를 제공해서 모든 유형의 시각적 콘텐츠를 추가할 수 있고 맨 위의 레이어에 영향을 주지 않도록 합니다. 결국 레이어 계층 구조를 망치지 않고도 무엇이든 움직일 수 있도록 해주므로 애니메이션에서 즐겨 사용하게 됩니다.

LinearLayoutViewOverlay가 있는 상황에서 ViewOverlay에 애니메이션을 추가하고 싶다면 어떻게 할까요?

android-ui-overlay

LinearLayoutgetOverlay를 사용한 후 오버레이를 추가합니다. 그러면 해당 ImageView는 더이상 LinearLayout에 속하지 않게 되죠. 오른쪽처럼 임시 ViewOverlay 안에 들어가게 되므로 이를 애니메이션에 사용할 수 있습니다. 이러면 ImageViewrect가 부모에서 무효화되면서 강제로 다시 레이아웃됩니다. 해당 뷰에 의존적이라면 전체 계층 구조에 영향을 줄 수 있으니 조심해야 합니다.

이런 개발 뉴스를 더 만나보세요

반면 모든 터치 이벤트나 애니메이션이 해당 ImageView를 사용해서 이 ViewOverlay에 위임된다는 겁니다. 이 ImageView의 참조가 있고 애니메이션을 하는 경우를 생각해 볼까요? 더이상 LinearLayout 의 일원이 아니라도 애니메이션을 할 수 있습니다. 이 경우에는 ViewOverlay의 일부입니다.

공유 요소 전환은 꽤 훌륭하지만 여기에도 제한 사항이 있습니다. 첫 번째는 사용자가 터치로 전환을 제어할 수 없다는 겁니다. 한 시점에서 다른 시점으로 넘어가는 간단한 애니메이션일 뿐이므로 사용자의 시선을 사로잡고 화면 간의 컨텍스트를 잃지는 않지만, 사용자가 전환을 핀치 및 확대/축소로 전환을 제어할 수는 없습니다.

다른 제한 사항은 전환이 대상 목적지를 추적하지 않기 때문에 전환이 실행되는 동안 모든 터치 이벤트를 무효화하지 않으면 매우 이상하게 보일 수 있습니다. 이런 현상은 TransitionListener을 사용해서 해결할 수 있습니다.


/**
  * A transition listener receives notifications from a transition.
  * Notifications indicate transition lifecycle events.  
  */
public static interface TransitionListener {

    void onTransitionStart(Transition transition);  
    void onTransitionEnd(Transition transition);  
    void onTransitionCancel(Transition transition);  
    void onTransitionPause(Transition transition);  
    void onTransitionResume(Transition transition);
}

다행히 전환의 모든 생명 주기 이벤트를 받을 수 있습니다. 따라서 onTransitionStart에서 모든 터치 이벤트를 무효화하고 전환이 끝나면 다시 터치 이벤트가 제 역할을 하도록 합니다.


private View.OnTouchListener touchEater = new 
View.OnTouchListener() {
    @Override
    public boolean onTouch(View view, MotionEvent motionEvent) {
        return true;
    }
};

중요 속성

이제 이미지의 두 중요한 속성에 관해 이야기해볼까요? 이들은 일관적으로 부모 밖에서 그려질 수 있는 속성입니다. 먼저 ClipChildren부터 살펴보겠습니다.

ClipChildren (10:52)

ClipChildrenViewGroup의 속성입니다. 기본값은 true인데 자식이 부모 ViewGroup의 경계를 넘지 않게 해주므로 일상적인 동작이죠.

android-ui-clip-true

예제를 보여드리겠습니다. 캐릭터를 보여주는 ImageView는 파란색 사각형인 부모 뷰 바깥에서 그려지지 않습니다.

android-ui-clip-false

하지만 ClipChildrenfalse로 설정하면 첫 번째 부모는 넘어가고 두 번째 부모 내부에서까지만 보여집니다. 전체 부모의 값을 아래처럼 false로 설정하면 모든 경계를 넘어서 움직일 수 있겠죠.


<?xml version="1.0" encoding="utf-8"?>  
<FrameLayout
    android:id="@+id/parent"
    xmlns:android="http://schemas.android.com/apk/res/android"  
    android:layout_width="match_parent"  
    android:layout_height="match_parent"
    android:clipChildren="false"
    android:padding="@dimen/activity_vertical_margin">  
    <FrameLayout
        android:layout_width="300dp"  
        android:layout_height="300dp"  
        android:background="@color/colorAccent"  
        android:clipChildren="false">  
        <FrameLayout
            android:layout_width="200dp"  
            android:layout_height="200dp"  
            android:background="@color/colorPrimary">  
            <ImageView
                android:id="@+id/imageview"  
                android:layout_width="100dp"  
                android:layout_height="100dp"  
                android:src="@drawable/profile"/>
        </FrameLayout>  
    </FrameLayout>
</FrameLayout>

ClipPadding

ClipPadding도 유사합니다. ClipChildrenfalse로 설정했지만, 아직 패딩이 있으므로 그 패딩에 잘립니다. 특히 RecyclerViews에서 자주 나타나는데요. RecyclerViews 내에서 갑자기 뷰가 잘린다면 이 값을 false로 하지 않아서일 겁니다.

유틸리티

뷰의 모든 부모를 찾아서 모든 값을 false로 바꾸기 위해 예전에 만들었던 유틸리티를 공유합니다.


public static void disableParentsClip(@NonNull View view) {  
    while (view.getParent() != null &&
            view.getParent() instanceof ViewGroup) {  
        ViewGroup viewGroup = (ViewGroup) view.getParent();  
        viewGroup.setClipChildren(false);  
        viewGroup.setClipToPadding(false);
        view = viewGroup;
    }  
}

public static void enableParentsClip(@NonNull View view) {
    while (view.getParent() != null &&
            view.getParent() instanceof ViewGroup) {
        ViewGroup viewGroup = (ViewGroup) view.getParent();  
        viewGroup.setClipChildren(true);  
        viewGroup.setClipToPadding(true);
        view = viewGroup;
    }
}

배울 점: 모든 것이 빠르게 움직이면 괜찮게 보입니다.

저는 Google Photos를 좋아합니다. 갤러리 중에 이보다 멋진 UI 경험을 제공하는 앱은 아직 보지 못했기 때문에 최고 수준이라고 생각합니다.

android-ui-clip-google-gallery

레이어 하나로 시작해서 두 개의 레이어가 보이고 전체 화면이 됩니다. 3 단계의 줌을 통해 전체 화면으로 전환되죠. 사용자는 전체 화면으로의 전환을 제어할 수 있는데, 앞서 말한 공유 요소 전환에서는 불가능한 제한 사항입니다.

UI에서 일어나는 것을 알기 위해서는 기기의 개발자 옵션을 사용합니다. “전환 애니메이션 배율”과 “Animation 길이 배율”을 “10x”로 설정하면 애니메이션이 10배 느리게 동작하기 때문에 어떤 일이 일어나는지 확인할 수 있습니다.

android-ui-clip-layout-boundary

또 다른 개발자 옵션은 “레이아웃 범위 표시”로 개발자에게 정말 유용합니다. 클립 경계선인 파란 선과 보이는 경계인 붉은 선, 마진을 표시하는 분홍색 영역이 있어서 전체 화면의 정렬을 볼 수 있습니다.

android-ui-clip-animation

두 개의 다른 RecyclerView가 있고 보다 큰 사이즈를 가진 보이지 않는 다른 RecyclerView가 있는 것을 확인할 수 있습니다. 이들이 서로 교차 페이드되고 있습니다. 이것이 이 앱의 마술입니다. 여기서 배울 점은 모든 것이 빠르게 움직이면 효과가 좋다는 겁니다. 모든 애니메이션을 10배로 느리게 하고 스마트폰을 사용해보면 애플리케이션들이 정말 희한한 동작을 한다는 것을 깨달을 수 있을 겁니다. 빠른 전환과 애니메이션으로 이런 구현상의 어색한 부분을 감출 수 있습니다.

android-ui-fullscreen

전체 화면 사진을 확대하려고 하면 같은 사진이 보이는데, 이를 눈속임했습니다. 아마 같은 drawable을 복사하고 전체 화면으로 전환하는 동안 사용할 겁니다. 이를 빠르게 보이면 스케일이 충분히 않더라도 잘 동작하는 것처럼 보입니다. 이런 부분에서 Google 개발자들은 정말 마술사와 비슷합니다. 실제 만들기 전에는 눈속임으로 덮을 수 있다는 이런 트릭을 우리 개발자들은 항상 활용할 수 있습니다.

데모

설명을 위해 Google Photos를 단순화한 데모를 만들었습니다. 2 단계로 확대/축소할 수 있고 마지막 단계는 전체 화면입니다. 어떤 마술이 사용됐는지 전체 코드에서 볼 수 있습니다.

android-ui-demo

보시는 것처럼 container, mediumRecyclerView, smallRecyclerView, fullScreenImageContainer 레이어가 있습니다. FrameLayout인 컨테이너는 전체 계층 구조 레이아웃의 루트가 됩니다. 각 레이어가 다른 레이어의 위에 쌓이므로 FrameLayout을 사용했고 전체 화면으로 보이게 됩니다.

공유 요소 전환을 통해 사용자가 전체 화면 전환을 제어할 수 없으므로 해당 화면에 전체 화면 컨테이너를 배치했습니다. 직접 NavigationManager를 만들어서 구현할 수도 있습니다. 상태에 따라 스택이 생기고, 스택에 따라 각 레이어를 위한 프리젠터와 뷰 델리게이트를 초기화할 수 있습니다.

더 중요한 부분은 중간 크기과 작은 크기의 RecyclerView입니다. 이제부터는 smallRecyclerView를 이미지라고 부르겠습니다. mediumRecyclerView는 전체 화면으로 전환되기 전의 더 높은 단계 확대입니다. 두 개의 RecyclerView를 사용해서 갤러리의 줌인 효과를 멋지게 만들어주는 트릭을 보여드리겠습니다.

android-ui-clip-pivot

기본 피벗 은 뷰의 정중앙에 있습니다. 그래서 어떤 뷰의 크기를 조정하려고 하면 가운데에서부터 모든 방향으로 커지죠. 이런 동작이 아니라 바로 커지게 하려면 어떻게 할까요? 먼저 피벗을 RecyclerView인 뷰의 왼쪽 위로 지정합니다. 이 설정은 크기 조절뿐만 아니라 회전에도 중요합니다.


/**
* Sets the x location of the point around which the view * is {@link #setRotation(float) rotated} and
* {@link #setScaleX(float) scaled}.
* By default, the pivot point is centered on the object.
*/
smallRecyclerView.setPivotX(0);
smallRecyclerView.setPivotY(0);
mediumRecyclerView.setPivotX(0);  
mediumRecyclerView.setPivotY(0);

RecyclerView 두 개가 있고, 저는 여기 매핑에서 같은 컬렉션으로 묶인 두 개의 어댑터를 사용했습니다. 어댑터들이 이미지는 같지만 사이즈만 다를 뿐인 같은 데이터를 사용하기 때문입니다. 처음에 확대를 시작할 작은 뷰를 먼저 보여줘야 하므로 중간 크기의 뷰를 보이지 않게 설정하는 것이 중요합니다.


smallRecyclerView.setAdapter(smallAdapter); 
mediumRecyclerView.setAdapter(mediumAdapter);  
mediumRecyclerView.setVisibility(INVISIBLE);

이제 터치 이벤트를 처리해볼까요?


ItemTouchListenerDispatcher dispatcher =
      new ItemTouchListenerDispatcher(this,
      galleryGestureDetector, fullScreenGestureDetector);

smallRecyclerView.addOnItemTouchListener(onItemTouchListener);

mediumRecyclerView.addOnItemTouchListener(onItemTouchListener);

다른 방법도 있겠지만 여기서는 ItemTouchListenerDispatcher를 만들어서 모든 터치 이벤트를 받게 했습니다. 뷰의 상태에 따라 하나 혹은 여러 개의 클래스에 해당 터치를 위임합니다.


public class ItemTouchListenerDispatcher implements RecyclerView.OnItemTouchListener {
  ...
      @Override
      public void onTouchEvent(RecyclerView rv, MotionEvent e) {
          currentSpan = getSpan(e);  
          switch (rv.getId()) {
              case R.id.mediumRecyclerView: {  
                  if (currentSpan < 0) {
                      galleryGestureDetector.onTouchEvent(e);  
                  } else if (currentSpan == 0) {
                      final View childViewUnder = rv.findChildViewUnder(e.getX(), e.getY());  
                      if (childViewUnder != null) {
                          childViewUnder.performClick();  
                      }
                  }
                  break;  
              }
              case R.id.smallRecyclerView: {      
                  galleryGestureDetector.onTouchEvent(e);
                  break;  
              }
              default: {
                  break;  
              }
        }
    }  
...
}

클래스를 만들고 이를 양쪽 어댑터에 모두 추가했습니다. 더 많은 일을 하긴 하지만 가장 중요한 부분은 터치 이벤트죠. 두 개의 RecyclerView가 같은 것을 사용하므로 ID를 사용해서 어떤 RecyclerView가 터치 이벤트를 받을지 알 수 있습니다.

RecyclerView의 크기 조절을 위해 사용하는 galleryGestureDetector도 보입니다. smallRecyclerView가 터치를 받는다면 확대돼서 mediumRecyclerView로만 갈 수 있습니다. 이런 작동을 galleryGestureDetector에 위임해서 확대를 실행하게 합니다.

mediumRecyclerView의 경우도 마찬가지입니다. galleryGestureDetector에서 작동을 하죠. 중간 크기에서 축소돼서 작은 크기로 갑니다. 터치하면 전체 화면이 됩니다.

스팬 도 사용했습니다. 스팬은 마지막 스팬과 현재 스팬의 차이값을 뜻하는데, 손가락 간의 거리로 핀치와 확대를 할 수 있게 합니다. 이를 가지고 RecyclerView, 예제에서는 mediumRecyclerView에 이 좌표의 뷰를 달라고 요청한 다음 클릭을 수행합니다. 이런 방식으로 위 예제의 중간 크기와 작은 크기의 확대와 축소가 RecyclerView가 작동할 수 있습니다.

제스쳐로 크기 조절 (24:32)

하지만 아직 크기 조절을 하는 것은 아니고 터치 이벤트만을 보냈을 뿐이죠. 크기 조절을 하려면 OnScaleGestureListener의 메서드인 onScaleBegin, onScaleEnd를 활용해야 합니다. onScaleBegin에서는 크기 조절을 시작하는 데 필요한 모든 것을 설정합니다. 크기 조절은 제스쳐의 연속적인 스트림으로 일어납니다. onScaleEnd에서 사용자가 화면 터치를 멈추게 되고, 원하는 상태에 맞게 위치를 재설정해줍니다.


public interface OnScaleGestureListener {  
  /**
    * Responds to scaling events for a gesture in progress.
    * Reported by pointer motion.
    */
  public boolean onScale(ScaleGestureDetector detector);

  /**
    * Responds to the beginning of a scaling gesture. Reported by  
    * new pointers going down.
    */
  public boolean onScaleBegin(ScaleGestureDetector detector);

  /**
    * Responds to the end of a scale gesture. Reported by existing  
    * pointers going up.

    * @param detector The detector reporting the event - use this to  
    *          retrieve extended info about event state.
    */
    public void onScaleEnd(ScaleGestureDetector detector);  
}

크기 조절 시작

onScaleBegin에서는 작은 크기와 중간 크기의 RecyclerView를 동시에 보여줘야 합니다. 이는 확대나 축소 중간에 일어나는데, 양쪽이 모두 보이는 동안 각 RecyclerView 사이에 페이드 인과 아웃을 만들어서 구현합니다. 물론 이것 말고도 다른 방법들이 있겠지만 onScaleEnd에서 이 마술의 진가를 볼 수 있을 겁니다.


@Override
public boolean onScaleBegin(@NonNull ScaleGestureDetector detector) { 
    mediumRecyclerView.setVisibility(View.VISIBLE);  
    smallRecyclerView.setVisibility(View.VISIBLE);  
    return true;
}

크기 조절 중

onScale에서 모든 수학적 계산이 일어납니다. 또한 인체에서 자연스럽게 일어날 수 있는 떨림을 방지하기 위해 gestureTolerance도 여기 포함됩니다. 화면은 작은 증가나 감소로 이뤄진 연속적인 신호 이벤트를 받습니다. 필터를 통과했으면 최소 스팬 조건을 충족했으므로 크기를 조절합니다.


@Override
public boolean onScale(@NonNull ScaleGestureDetector detector) {
    if (gestureTolerance(detector)) {  
        //small
        scaleFactor *= detector.getScaleFactor();
        scaleFactor = Math.max(1f, Math.min(scaleFactor, SMALL_MAX_SCALE_FACTOR));  
        isInProgress = scaleFactor > 1;
        smallRecyclerView.setScaleX(scaleFactor);  
        smallRecyclerView.setScaleY(scaleFactor);

        //medium
        scaleFactorMedium *= detector.getScaleFactor();
        scaleFactorMedium = Math.max(0.8f, Math.min(scaleFactorMedium, 1f));
        mediumRecyclerView.setScaleX(scaleFactorMedium);  
        mediumRecyclerView.setScaleY(scaleFactorMedium);

        //alpha
        mediumRecyclerView.setAlpha((scaleFactor - 1) / (0.25f));  
        smallRecyclerView.setAlpha(1 - (scaleFactor - 1) / (0.25f));
    }
    return true;  
}

scaleFactorscaleFactorMedium가 있는데, 이 중 scaleFactorScaleGestureDetector에서 얻습니다. 이를 클램프 함수에 사용해서 양 RecyclerView의 크기를 조절할 수 있는 최솟값과 최댓값으로 지정했습니다. 물론 각각은 상대편의 역수이고, 서로 쌍이 맞아야겠죠? 그렇지 않으면 전환 중에 같은 크기가 아니라서 페이드인/아웃 효과가 지저분해질 겁니다. 이 조건만 지킨다면 숫자나 함수는 원하는 대로 조정해도 좋습니다.

알파도 같습니다. 전환 중간에 적용할 알파를 양 RecyclerView에 적용하면 됩니다. 시작 시점에는 smallRecyclerView의 값을 1, 중간 크기를 0으로 하고, 종료 시점에는 반대로 지정합니다.

만약 사용자가 전환 중에 터치를 중단하면 어떻게 될까요? 따로 구현하지 않으면 전환 중 상태 그대로 멈출 테고 아마 양 RecyclerView가 모두 보일 겁니다. 아마 아주 이상하게 보이겠죠?

Scale ends

onScaleEnd에서 이런 구현을 감출 수 있는 마술과 같은 트릭을 구현했는데, 어느 한 상태로 자동으로 넘어가도록 전환을 종료하는 겁니다.


@Override
public void onScaleEnd(@NonNull ScaleGestureDetector detector)
{
    if (IsScaleInProgress()) {
        if (scaleFactor < TRANSITION_BOUNDARY) {  
            transitionFromMediumToSmall();  
            scaleFactor = 0;
            scaleFactorMedium = 0;
        } else {
            transitionFromSmallToMedium();  
            scaleFactor = SMALL_MAX_SCALE_FACTOR;  scaleFactorMedium = 1f;
        }  
    }
}

이런 동작을 위해 TRANSITION_BOUNDARY를 사용했습니다. 델타를 계산해서 전환을 마치기 위해서 얼마나 필요한지 확인하고, 만약 20~30% 이하라면 사용자가 전체 화면으로 전환하길 원하지 않는다는 가정을 하고 작은 이미지로 돌아가게 했습니다.

그렇지 않으면, 즉 TRANSITION_BOUNDARY 이상 값이면 사용자가 전체 화면으로 전환하고자 했지만 너무 빨리 움직여서 끝까지 다 움직인 것으로 체크되지 않았다고 가정했습니다. 이런 가정이 없으면 중간 상태에서 멈춘 것을 해결할 수 없을 겁니다. 관객에게 마술사의 트릭이 다 들키는 셈이죠.

fullScreenImageContainer (29:07)

android-ui-fullscreen

전체 화면에서는 검은 색 배경 위에 전체 너비로 이미지를 보여줍니다. 이를 구현하려면 RecyclerView에서 처리해야 할 것이 있습니다. RecyclerView에 아이템을 추가하는 경우, 한 아이템을 추가하고 다시 다른 아이템을 추가할 때 더 높은 레벨이 됩니다. 그래서 첫 번째 아이템의 크기를 조절하려고 하면 두 번째 아이템 밑에 그려지죠. 이상한 모습이 되겠죠? 각 아이템은 RecyclerView에서 높이를 가져야 합니다.

RecyclerView 높이(elevation) (29:29)

앞서 말한 문제를 방지하는 몇 가지 방법이 있습니다.

  1. 가장 어려운 방법은 사용자가 해당 항목을 클릭할 때를 알고 있는 커스텀 LayoutManager를 만드는 겁니다. 하나의 아이템을 클릭하면 이것이 다른 것보다 위에서 그려지도록 합니다.

  2. 덜 어려운 방법은 RecyclerView.ChildDrawingOrderCallback을 사용하는 겁니다. 이 콜백을 받으면 크기 조절을 원하는 항목의 높이를 다른 항목보다 높입니다. 그래서 맨 위에 놓이게 하죠.

  3. 가장 쉬운 방법은? ViewOverlay로 눈속임하는 것입니다. 핀치로 전체 화면으로 확대되도록 전환하기 위해 RecyclerView 위에 ViewOverlay를 사용할 수 있습니다. 그다음 해당 아이템을 추가하고 해당 뷰를 프레임레이아웃에 넣습니다. 여기서는 fullScreenContainer입니다. 이 쉬운 방법을 놓고 굳이 어렵게 하실 필요가 없겠죠!

전체 화면으로 전환하기 (31:07)


@Override
public void onClick(@NonNull final View itemView) {
    ViewGroupOverlay overlay = fullScreenContainer.getOverlay();  
    overlay.clear();
      overlay.add(itemView);  
      fullScreenContainer.setBackgroundColor(TRANSPARENT);  
      fullScreenContainer.setVisibility(View.VISIBLE);  
      itemView.animate()
              .x(DELTA_TO_CENTER_X).y(DELTA_TO_CENTER_Y)  
              .scaleX(DELTA_SCALE).scaleY(DELTA_SCALE) 
              .withEndAction(setTransitionToRecyclerView()).start();
          }  
}

fullScreenContainer에서 오버레이를 받습니다. 다른 아이템이 이전에 확대됐는지 알 수 없으니 일단 오버레이의 상태를 지웁니다. 이제 새 아이템을 추가합니다. 여기서 itemView 매개 변수는 onClick 안에 있습니다. itemTouchListenerDispatcher에서 예전에 수행했던 클릭이죠. 이제 해당 itemView를 움직여서 전체 화면으로 만듭니다. withEndAction에는 클릭 리스너를 설정해서 mediumRecyclerView인 갤러리로 돌아갈 수 있게 합니다.

해당 메서드를 살펴볼까요?

private Runnable setTransitionToRecyclerView() {  
    return new Runnable() {
        @Override
        public void run() {
            fullScreenContainer.setBackgroundColor(BLACK);  
            fullScreenContainer.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    overlay.add(itemView);  
                    fullScreenContainer.setBackgroundColor(TRANSPARENT);  
                    itemView.animate().x(originX).y(originY)
                            .scaleY(1).scaleX(1).withEndAction(  
                            new Runnable() {
                                @Override
                                public void run() {
                                    overlay.remove(itemView);
                                    fullScreenContainer.setVisibility(View.GONE);  
                                }
                            }).start();
                    }  
                });
            }  
        };
}

메서드에는 배경을 검은색으로 만들거나 배경을 투명으로 만들거나 visibility를 GONE으로 만드는 등의 기능이 더 들어 있습니다. 핵심 아이디어는 오버레이를 전환에 사용한다는 겁니다. 전환이 마무리되는 상태 종료 시점에서는 갤러리 애플리케이션이 전체 화면에서 로직을 수행할 수 있도록 실제 부모에게 해당 뷰를 다시 돌려놓습니다.

전체 화면의 이미지에 있는 아이템을 다시 클릭하면 다시 mediumRecyclerView로 전환돼야 합니다. 먼저 해당 아이템을 오버레이에 추가합니다. 그다음 애니메이션을 위해 originX, originY를 설정하고 해당 이미지의 원본 사이즈인 1값으로 크기를 조절합니다. 오버레이로부터 아이템을 없애지 않으면 프레임워크에서 재활용할 때까지 남아있을 테니 꼭 지워주세요.

android-ui-demo-final

이 방법으로 공유 요소 전환을 사용하지 않고도 사용자가 쉽게 전환 중에 상호작용할 수 있습니다. 레이어를 만들고 위로 얹기만 하면 위와 같은 확대, 축소, 전체화면 클릭 리스너를 포함하는 전환 마술 트릭이 가능합니다.

마술 트릭 (34:08)

애니메이션에 사용할 수 있는 마술 트릭을 정리해 드리겠습니다.

  • 부모와 패딩을 넘어서서 그리려면 ClipPaddingClipChildren을 사용하세요. 뷰에서 뭔가 잘려 보인다면 이 값이 false로 설정돼있기 때문일 수 있습니다.
  • 애니메이션에는 ViewOverlay를 강력히 추천합니다. 모든 레이어 계층 구조를 넘어서 그릴 수 있고, 구조를 망치지 않고도 무엇이든 할 수 있습니다.
  • 공유 요소 전환을 사용하면 사용자가 이벤트 없이 전환을 제어할 수 없습니다. 이를 허용하려면 액티비티를 만들고 직접 내비게이션을 만들어야 하는데, 복잡한 로직까지는 필요하지 않으며 단순하게 보여주고 감추고 다음으로 넘어가거나 화면으로 들어가거나 나가는 등의 것만 구현하면 됩니다.
  • 마지막으로 빠른 애니메이션 속도로 구현상의 문제를 감출 수 있습니다. 충분히 빠른 속도라면 훌륭하고 매끄럽게 보일 수 있죠.

마술사들의 트릭처럼 사용자의 눈이 트릭을 눈치채지 못할 만큼 충분하게 빠른 속도로 만드세요.

더욱 많은 트릭을 보고 싶다면 Nick Butcher의 Plaid를 참고하세요.

다음: RecyclerView와 Realm으로 만드는 Grid Layout

General link arrow white

컨텐츠에 대하여

2016년 10월에 진행한 goto; Copenhagen 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Israel Ferrer Camacho

Israel은 Android Cupcake 시절부터 Android 앱을 개발해 왔습니다. 사용자가 Material 디자인을 듬뿍 경험하게 하면서도 재사용과 테스트가 가능하고 유지보수에 용이한 개발에 관심이 있습니다. 현재 Twitter에서 Android 개발자로 일하고 있습니다.

4 design patterns for a RESTless mobile integration »

close