적응형 앱

1. 시작하기 전에

기본 요건

필요한 항목

학습할 내용

  • 적응형 레이아웃 및 탐색 3 기본사항
  • 드래그 앤 드롭 구현
  • 단축키 지원
  • 컨텍스트 메뉴 사용 설정

2. 설정

시작하려면 다음 단계를 따르세요.

  1. Android 스튜디오를 시작합니다.
  2. File(파일) > New(새 파일) > Project from Version control을 클릭합니다.
  3. URL을 붙여넣습니다.
https://github.com/android/socialite.git
  1. Clone을 클릭합니다.

프로젝트가 완전히 로드될 때까지 기다립니다.

  1. 터미널을 열고 다음을 실행합니다.
$ git checkout codelab-adaptive-apps-start
  1. Gradle 동기화를 실행합니다.

Android 스튜디오에서 File > Sync Project with Gradle Files를 선택합니다.

  1. (선택사항) 대형 데스크톱 에뮬레이터를 다운로드합니다.

Android 스튜디오에서 Tools > Device Manager > + > Create Virtual Device > New hardware profile을 선택합니다.

기기 유형 선택: Desktop

화면 크기: 14인치

해상도: 1920x1080픽셀

Finish를 클릭합니다.

  1. 태블릿 또는 데스크톱 에뮬레이터에서 앱을 실행합니다.

3. 샘플 앱 이해

이 튜토리얼에서는 Jetpack Compose로 빌드된 Socialite라는 샘플 채팅 애플리케이션을 사용합니다. e9e4541f0f76d669.png

이 앱에서는 다양한 동물과 채팅할 수 있으며, 동물들은 각기 다른 방식으로 메시지에 응답합니다.

현재 앱은 태블릿이나 데스크톱과 같은 대형 기기에 최적화되지 않은 모바일 중심 애플리케이션입니다.

앱을 대형 화면에 맞게 조정하고 모든 폼 팩터에서 환경을 개선하기 위한 몇 가지 기능을 추가할 예정입니다.

그럼 시작해 볼까요?

4. 적응형 레이아웃 + 탐색 3 기본사항

$ git checkout codelab-adaptive-apps-step-1

현재 앱은 사용 가능한 화면 공간의 크기와 관계없이 항상 한 번에 하나의 창만 표시합니다.

현재 창 크기에 따라 하나 또는 여러 개의 창을 표시하는 adaptive layouts를 사용하여 이 문제를 해결하겠습니다. 이 Codelab에서는 적응형 레이아웃을 사용하여 창 공간이 충분한 경우 chat listchat detail 화면을 나란히 자동으로 표시합니다.

c549fd9fa64589e9.gif

적응형 레이아웃은 모든 애플리케이션에 원활하게 통합되도록 설계되었습니다.

이 튜토리얼에서는 Socialite 앱이 빌드된 탐색 3 라이브러리에서 이를 사용하는 방법에 관해 중점적으로 다룹니다.

탐색 3을 이해하기 위해 먼저 몇 가지 용어를 알아보겠습니다.

  • NavEntry: 사용자가 이동할 수 있는 앱 내에 표시되는 일부 콘텐츠입니다. 키로 고유하게 식별됩니다. NavEntry는 앱에서 사용할 수 있는 전체 창을 채울 필요가 없습니다. 두 개 이상의 NavEntry를 동시에 표시할 수 있습니다(자세한 내용은 나중에 설명).
  • : NavEntry의 고유 식별자입니다. 키는 백 스택에 저장됩니다.
  • 백 스택: 이전에 표시되었거나 현재 표시되고 있는 NavEntry 요소를 나타내는 키 스택입니다. 탐색하려면 키를 스택에 푸시하거나 스택에서 팝합니다.

Socialite에서는 사용자가 앱을 실행할 때 가장 먼저 표시할 화면이 채팅 목록입니다. 따라서 백 스택을 만들고 해당 화면을 나타내는 키로 초기화합니다.

Main.kt

// Create a new back stack
val backStack = rememberNavBackStack(ChatsList)

...

// Navigate to a particular chat
backStack.add(ChatThread(chatId = chatId))

...

// Navigate back
backStack.removeLastOrNull()

Main 진입점 컴포저블에서 탐색 3을 직접 구현합니다.

MainNavigation 함수 호출의 주석 처리를 삭제하여 탐색 로직을 연결합니다.

이제 탐색 인프라를 빌드해 보겠습니다.

먼저 백 스택을 만듭니다. 탐색 3의 핵심입니다.

지금까지 여러 탐색 3 개념을 살펴봤습니다. 하지만 라이브러리는 어떤 객체가 백 스택을 나타내는지, 그리고 그 요소를 실제 UI로 변환하는 방법을 어떻게 결정할까요?

NavDisplay를 소개합니다. 모든 것을 모으고 백 스택을 렌더링하는 구성요소입니다. 몇 가지 중요한 매개변수를 사용합니다. 그럼 하나씩 살펴보도록 하겠습니다.

매개변수 1 - 백 스택

NavDisplay는 콘텐츠를 렌더링하기 위해 백 스택에 액세스해야 합니다. 전달해 보겠습니다.

매개변수 2 - EntryProvider

EntryProvider는 백 스택 키를 컴포저블 UI 콘텐츠로 변환하는 람다입니다. 키를 사용하고 표시할 콘텐츠와 표시하는 방법에 관한 메타데이터가 포함된 NavEntry를 반환합니다(자세한 내용은 나중에 설명).

NavDisplay는 새 키가 백 스택에 추가되는 경우와 같이 지정된 키의 콘텐츠를 가져와야 할 때마다 이 람다를 호출합니다.

현재 Socialite에서 Timeline 아이콘을 클릭하면 '알 수 없는 백 스택 키: Timeline'이 표시됩니다.

532134900a30c9c.gif

Timeline 키가 백 스택에 추가되더라도 EntryProvider는 이를 렌더링하는 방법을 알지 못하므로 기본 구현으로 대체됩니다. Settings 아이콘을 클릭해도 마찬가지입니다. EntryProviderTimelineSettings 백 스택 키를 올바르게 처리하도록 하여 문제를 해결해 보겠습니다.

매개변수 3 - SceneStrategy

다음으로 중요한 NavDisplay의 매개변수는 SceneStrategy입니다. 여러 NavEntry 요소를 동시에 표시하려는 경우에 사용합니다. 각 전략은 여러 NavEntry 요소가 나란히 표시되거나 서로 겹쳐 표시되는 방식을 정의합니다.

예를 들어 DialogSceneStrategy를 사용하고 특수 메타데이터로 일부 NavEntry를 표시하면 전체 화면을 차지하는 대신 현재 콘텐츠 위에 대화상자로 표시됩니다.

여기서는 다른 SceneStrategy인 ListDetailSceneStrategy를 사용합니다. 표준 목록-세부정보 레이아웃용으로 설계되었습니다.

먼저 NavDisplay 생성자에 추가해 보겠습니다.

sceneStrategy = rememberListDetailSceneStrategy(),

이제 ChatList NavEntry를 목록 창으로, ChatThread NavEntry를 세부정보 창으로 표시해야 합니다. 그러면 전략에서 이러한 두 NavEntry 요소가 백 스택에 있을 때 나란히 표시되어야 하는지 결정할 수 있습니다.

다음 단계로 ChatsList NavEntry를 목록 창으로 표시합니다.

entryProvider = { backStackKey ->
   when (backStackKey) {
      is ChatsList -> NavEntry(
         key = backStackKey,
         metadata = ListDetailSceneStrategy.listPane(),
      ) {
         ...
      }
      ...
   }
}

마찬가지로 ChatThread NavEntry를 세부정보 창으로 표시합니다.

entryProvider = { backStackKey ->
   when (backStackKey) {
      is ChatThread -> NavEntry(
         key = backStackKey,
         metadata = ListDetailSceneStrategy.detailPane(),
      ) {
         ...
      }
      ...
   }
}

이제 적응형 레이아웃을 앱에 통합했습니다.

5. 드래그 앤 드롭

$ git checkout codelab-adaptive-apps-step-2

이 단계에서는 사용자가 Files 앱에서 Socialite로 이미지를 드래그할 수 있는 드래그 앤 드롭 지원을 추가합니다.

78fe1bb6689c9b93.gif

목표는 ChatScreen.kt 파일에 있는 MessageList 컴포저블로 정의된 message list 영역에서 드래그 앤 드롭을 사용 설정하는 것입니다.

Jetpack Compose에서는 드래그 앤 드롭 지원이 dragAndDropTarget 수정자에 의해 구현됩니다. 드롭된 항목을 수락해야 하는 컴포저블에 적용합니다.

Modifier.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       // condition to accept dragged item
   },
   target = // DragAndDropTarget
)

이 수정자에는 두 개의 매개변수가 있습니다.

  • 첫 번째 매개변수 shouldStartDragAndDrop은 컴포저블이 드래그 앤 드롭 이벤트를 필터링할 수 있도록 합니다. 여기서는 이미지만 허용하고 다른 모든 유형의 데이터는 무시하려고 합니다.
  • 두 번째 매개변수 target은 허용된 드래그 앤 드롭 이벤트를 처리하는 로직을 정의하는 콜백입니다.

먼저 MessageList 컴포저블에 dragAndDropTarget를 추가해 보겠습니다.

.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       event.mimeTypes().any { it.startsWith("image/") }
   },
   target = remember {
       object : DragAndDropTarget {
           override fun onDrop(event: DragAndDropEvent): Boolean {
               TODO("Not yet implemented")
           }
       }
   }
),

target 콜백 객체는 DragAndDropEvent를 인수로 사용하는 onDrop() 메서드를 구현해야 합니다.

이 메서드는 사용자가 컴포저블에 항목을 드롭할 때 호출됩니다. 항목이 처리된 경우 true를 반환하고 거부된 경우 false를 반환합니다.

DragAndDropEvent에는 드래그되는 데이터를 캡슐화하는 ClipData 객체가 포함됩니다.

ClipData 내의 데이터는 Item 객체의 배열입니다. 한 번에 여러 항목을 드래그할 수 있으므로 각 Item은 그중 하나를 나타냅니다.

target = remember {
   object : DragAndDropTarget {
       override fun onDrop(event: DragAndDropEvent): Boolean {
           val clipData = event.toAndroidDragEvent().clipData
           if (clipData != null && clipData.itemCount > 0) {
               repeat(clipData.itemCount) { i ->
                   val item = clipData.getItemAt(i)
                   // TODO: Implement Item handling
               }
               return true
           }
           return false
       }
   }
}

Item은 URI, 텍스트 또는 Intent 형식의 데이터를 포함할 수 있습니다.

여기서는 이미지만 허용하므로 URI를 찾습니다.

Item에 URI가 포함된 경우 다음을 실행해야 합니다.

  1. URI에 액세스하기 위한 드래그 앤 드롭 권한을 요청합니다.
  2. URI를 처리합니다(여기서는 이미 구현된 onMediaItemAttached() 함수를 호출).
  3. 권한을 해제합니다.
override fun onDrop(event: DragAndDropEvent): Boolean {
   val clipData = event.toAndroidDragEvent().clipData
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
       && clipData != null && clipData.itemCount > 0) {
       repeat(clipData.itemCount) { i ->
           val item = clipData.getItemAt(i)
           val passedUri = item.uri?.toString()
           if (!passedUri.isNullOrEmpty()) {
               val dropPermission = activity
                   .requestDragAndDropPermissions(
                       event.toAndroidDragEvent()
                   )
               try {
                   val mimeType = context.contentResolver
                       .getType(passedUri.toUri()) ?: ""
                   onMediaItemAttached(MediaItem(passedUri, mimeType))
               } finally {
                   dropPermission.release()
               }
           }
       }
       return true
   }
   return false
}

이 시점에서 드래그 앤 드롭이 완전히 구현되며 Files 앱에서 Socialite로 사진을 드래그할 수 있습니다.

드롭된 항목을 이 영역에서 허용할 수 있음을 강조하는 시각적 테두리를 추가하여 더 나은 디자인을 만들어 보겠습니다.

이렇게 하려면 드래그 앤 드롭 세션의 여러 단계에 해당하는 추가 후크를 사용하면 됩니다.

  1. onStarted() : 드래그 앤 드롭 세션이 시작되고 이 DragAndDropTarget에서 항목을 수신할 수 있을 때 호출됩니다. 수신 세션의 UI 상태를 준비하는 것이 좋습니다.
  2. onEntered() : 드래그된 항목이 이 DragAndDropTarget의 경계로 들어갈 때 트리거됩니다.
  3. onMoved() : 드래그된 항목이 이 DragAndDropTarget의 경계 내에서 이동할 때 호출됩니다.
  4. onExited() : 드래그된 항목이 이 DragAndDropTarget의 경계 밖으로 이동할 때 호출됩니다.
  5. onChanged() : 이 타겟의 경계 내에 있는 동안 드래그 앤 드롭 세션에서 무언가가 변경될 때 호출됩니다(예: 특수키를 누르거나 뗄 때).
  6. onEnded() : 드래그 앤 드롭 세션이 종료될 때 호출됩니다. 이전에 onStarted 이벤트를 수신한 모든 DragAndDropTarget이 이벤트를 수신합니다. UI 상태를 재설정하는 데 유용합니다.

시각적 테두리를 추가하려면 다음을 실행해야 합니다.

  1. 드래그 앤 드롭이 시작될 때 true로 설정되고 종료될 때 false로 다시 재설정되는 기억된 불리언 변수를 만듭니다.
  2. 이 변수가 true일 때 테두리를 렌더링하는 수정자를 MessageList 컴포저블에 적용합니다.
override fun onEntered(event: DragAndDropEvent) {
   super.onEntered(event)
   isDraggedOver = true
}

override fun onEnded(event: DragAndDropEvent) {
   super.onExited(event)
   isDraggedOver = false
}

6. 단축키

$ git checkout codelab-adaptive-apps-step-3

데스크톱에서 채팅 앱을 사용할 때 사용자는 Enter 키로 메시지를 보내는 것과 같이 익숙한 단축키를 기대합니다.

이 단계에서는 이러한 동작을 앱에 추가합니다.

Compose의 키보드 이벤트는 수정자로 처리됩니다.

크게 두 가지가 있습니다.

  • onPreviewKeyEvent: 포커스가 있는 요소에서 처리되기 전에 키보드 이벤트를 가로챕니다. 구현의 일환으로 이벤트를 더 전파할지 아니면 소비할지 결정합니다.
  • onKeyEvent: 포커스가 있는 요소에서 처리된 후에 키보드 이벤트를 가로챕니다. 다른 핸들러가 이벤트를 소비하지 않은 경우에만 트리거됩니다.

여기서는 기본 핸들러가 Enter 키 이벤트를 소비하고 커서를 새 줄로 이동하므로 TextField에서 onKeyEvent를 사용하면 작동하지 않습니다.

.onPreviewKeyEvent { keyEvent ->
   //TODO: implement key event handling
},

수정자 내의 람다는 키 입력마다 두 번 호출됩니다. 사용자가 키를 누를 때 한 번, 키를 뗄 때 한 번 호출됩니다.

KeyEvent 객체의 type 속성을 확인하여 어느 쪽인지 확인할 수 있습니다. 이벤트 객체는 다음과 같은 수정자 플래그도 노출합니다.

  • isAltPressed
  • isCtrlPressed
  • isMetaPressed
  • isShiftPressed

람다에서 true를 반환하면 Compose에 코드가 키 이벤트를 처리했음을 알리고 줄바꿈 삽입과 같은 기본 동작을 방지합니다.

이제 onPreviewKeyEvent 수정자를 구현합니다. 이벤트가 눌린 Enter 키와 일치하고 shift, alt, ctrl 또는 meta 수정자가 적용되지 않았는지 확인합니다. 그런 다음 onSendClick() 함수를 호출합니다.

.onPreviewKeyEvent { keyEvent ->
   if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown
       && keyEvent.isShiftPressed == false
       && keyEvent.isAltPressed == false
       && keyEvent.isCtrlPressed == false
       && keyEvent.isMetaPressed == false) {
       onSendClick()
       true
   } else {
       false
   }
},

7. 컨텍스트 메뉴

$ git checkout codelab-adaptive-apps-step-4

컨텍스트 메뉴는 적응형 UI의 중요한 부분입니다.

이 단계에서는 사용자가 메시지를 마우스 오른쪽 버튼으로 클릭할 때 표시되는 Reply 팝업 메뉴를 추가합니다.

d9d30ae7e0230422.gif

기본적으로 지원되는 다양한 동작이 있습니다. 예를 들어 clickable 수정자를 사용하면 클릭을 쉽게 감지할 수 있습니다.

마우스 오른쪽 버튼 클릭과 같은 맞춤 동작의 경우 pointerInput 수정자를 사용할 수 있습니다. 이 수정자를 사용하면 원시 포인터 이벤트에 액세스하고 동작 감지를 완전히 제어할 수 있습니다.

먼저 마우스 오른쪽 버튼 클릭에 응답하는 UI를 추가해 보겠습니다. 여기서는 단일 항목인 Reply 버튼이 있는 DropdownMenu를 표시하려고 합니다. remember로 변환된 변수가 2개 필요합니다.

  • rightClickOffset은 클릭 위치를 저장하므로 Reply 버튼을 커서 근처로 이동할 수 있습니다.
  • isMenuVisibleReply 버튼을 표시하거나 숨길지 제어합니다.

값은 마우스 오른쪽 버튼 클릭 동작 처리의 일부로 업데이트됩니다.

또한 DropdownMenu가 그 위에 겹쳐져 표시될 수 있도록 메시지 컴포저블을 Box로 래핑해야 합니다.

@Composable
internal fun MessageBubble(
   ...
) {
   var rightClickOffset by remember { mutableStateOf<DpOffset>(DpOffset.Zero) }
   var isMenuVisible by remember { mutableStateOf(false) }
   val density = LocalDensity.current

   Box(
       modifier = Modifier
           .pointerInput(Unit) {
               // TODO: Implement right click handling
           }
           .then(modifier),
   ) {
       AnimatedVisibility(isMenuVisible) {
           DropdownMenu(
               expanded = true,
               onDismissRequest = { isMenuVisible = false },
               offset = rightClickOffset,
           ) {
               DropdownMenuItem(
                   text = { Text("Reply") },
                   onClick = {
                       // Custom Reply functionality
                   },
               )
           }
       }
       MessageBubbleSurface(
           ...
       ) {
           ...
       }
   }
}

이제 pointerInput 수정자를 구현해 보겠습니다. 먼저 사용자가 새 동작을 시작할 때마다 새 범위를 시작하는 awaitEachGesture을 추가합니다. 이 범위 내에서 다음 작업을 해야 합니다.

  1. 다음 포인터 이벤트 가져오기: awaitPointerEvent()는 포인터 이벤트를 나타내는 객체를 제공합니다.
  2. 순수한 마우스 오른쪽 버튼 눌림 필터링: 보조 버튼만 눌렸는지 확인합니다.
  3. 클릭 위치 캡처: 픽셀 단위의 위치를 가져와 DpOffset으로 변환하여 메뉴 배치가 DPI와 무관하도록 합니다.
  4. 메뉴 표시: isMenuVisible = true를 설정하고 DropdownMenu가 포인터가 있던 위치에 정확하게 팝업되도록 오프셋을 저장합니다.
  5. 이벤트 소비: 누르기 및 이와 일치하는 누르기 해제 모두에서 consume()을 호출하여 다른 핸들러가 반응하지 못하도록 합니다.
.pointerInput(Unit) {
   awaitEachGesture { // Start listening for pointer gestures
       val event = awaitPointerEvent()

       if (
           event.type == PointerEventType.Press
           && !event.buttons.isPrimaryPressed
           && event.buttons.isSecondaryPressed
           && !event.buttons.isTertiaryPressed
           // all pointer inputs just went down
           && event.changes.fastAll { it.changedToDown() }
       ) {
           // Get the pressed pointer info
           val press = event.changes.find { it.pressed }
           if (press != null) {
               // Convert raw press coordinates (px) to dp for positioning the menu
               rightClickOffset = with(density) {
                   isMenuVisible = true // Show the context menu
                   DpOffset(
                       press.position.x.toDp(),
                       press.position.y.toDp()
                   )
               }
           }
           // Consume the press event so it doesn't propagate further
           event.changes.forEach {
               it.consume()
           }
           // Wait for the release and consume it as well
           waitForUpOrCancellation()?.consume()
       }
   }
}

8. 축하합니다

축하합니다. 앱을 탐색 3으로 이전하고 다음을 추가했습니다.

  • 적응형 레이아웃
  • 드래그 앤 드롭
  • 단축키
  • 컨텍스트 메뉴

이제 완전한 적응형 앱을 빌드할 수 있는 견고한 기반이 마련되었습니다.

자세히 알아보기