Chrome OS용 Android 앱 최적화

Chromebook에서 Android 앱을 실행할 수 있어, 이제 사용자는 방대한 앱 생태계와 다양한 새로운 기능을 사용할 수 있습니다. 이는 개발자에게 좋은 소식이지만 사용성 기대치를 충족하고 뛰어난 사용자 환경을 제공하려면 특정 앱 최적화가 필요합니다. 이 코드랩에서는 최적화의 가장 일반적인 경우를 안내합니다.

f60cd3eb5b298d5d.png

빌드할 프로그램

Chrome OS와 관련된 권장사항과 최적화를 보여줄 작동하는 Android 앱을 빌드합니다. 이 앱에는 아래의 기능이 있습니다.

다음을 포함한 키보드 입력 처리

  • Enter 키
  • 화살표 키
  • Ctrl 및 Ctrl+Shift 단축키
  • 현재 선택한 항목의 시각적 피드백

다음을 포함한 마우스 입력 처리

  • 마우스 오른쪽 버튼 클릭
  • 마우스 오버 효과
  • 도움말
  • 드래그 앤 드롭

아키텍처 구성요소를 사용하여 아래 작업 실행

  • 상태 유지
  • 자동으로 UI 업데이트

52240dc3e68f7af8.png

과정 내용

  • Chrome OS에서 키보드와 마우스 입력을 처리하기 위한 권장사항
  • Chrome OS 관련 최적화
  • ViewModelLiveData 아키텍처 구성요소의 기본 구현

필요한 항목

GitHub에서 저장소를 클론합니다.

git clone https://github.com/googlecodelabs/optimized-for-chromeos

또는 저장소의 ZIP 파일을 다운로드하고 압축을 풉니다.

Zip 파일 다운로드

프로젝트 가져오기

  • Android 스튜디오 열기
  • Import Project 또는 File > New > Import Project 선택
  • 프로젝트를 클론했거나 추출한 위치로 이동
  • 프로젝트 optimized-for-chromeos 가져오기
  • startcomplete라는 두 가지 모듈이 있음

앱 사용해 보기

  • start 모듈 빌드 및 실행
  • 트랙패드만 사용하여 시작
  • 공룡 클릭
  • 비밀 메시지 보내기
  • 'Drag Me' 텍스트를 드래그하거나 파일을 'Drop Things Here' 영역에 드래그해 보기
  • 키보드를 사용하여 메시지를 탐색하고 전송해 보기
  • 태블릿 모드에서 앱 사용해 보기
  • 기기를 회전하거나 창 크기를 조절해 보기

어떻게 생각하세요?

이 앱은 매우 기본적이고 잘못된 것처럼 보이는 부분을 쉽게 해결할 수 있지만 사용자 환경이 매우 나쁩니다. 수정해 보겠습니다.

a40270071a9b5ac3.png

키보드를 사용하여 비밀 메시지를 몇 개 입력하면 Enter 키가 작동하지 않는다는 것을 발견하실 것입니다. 이 점은 사용자에게 불편함을 줍니다.

아래의 샘플 코드와 키보드 작업 처리 문서를 통해 이 문제를 해결할 수 있습니다.

MainActivity.kt (onCreate)

// Enter key listener
edit_message.setOnKeyListener(View.OnKeyListener { v, keyCode, keyEvent ->
    if (keyEvent.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_ENTER) {
        button_send.performClick()
        return@OnKeyListener true
    }
    false
})

테스트해 보세요. 키보드만으로 메시지를 보낼 수 있게 되어 사용자 환경이 크게 개선되었습니다.

키보드만 사용할 때 이 앱을 탐색하기가 불편한가요? 이는 열악한 사용자 환경을 의미합니다. 키보드를 사용할 때 애플리케이션이 키보드에 반응하지 않으면 사용자는 불편함을 느낍니다.

뷰를 포커스 가능으로 설정하면 화살표와 Tab 키를 통해 쉽게 뷰를 탐색할 수 있습니다.

레이아웃 파일을 검토하고 ButtonImageView 태그를 확인합니다. focusable 속성이 false로 설정되어 있음을 알 수 있습니다. XML에서 이를 true로 변경합니다.

activity_main.xml

android:focusable="true"

프로그래매틱 방식으로:

MainActivity.kt

button_send.setFocusable(true)
image_dino_1.setFocusable(true)
image_dino_2.setFocusable(true)
image_dino_3.setFocusable(true)
image_dino_4.setFocusable(true)

직접 해 보세요. 화살표 키와 Enter 키를 사용하여 공룡을 선택할 수 있을 것입니다. 하지만 OS 버전과 화면, 조명에 따라 현재 선택된 항목이 보이지 않을 수 있습니다. 이를 해결하기 위해 이미지의 배경 리소스를 R.attr.selectableItemBackground로 설정합니다.

MainActivity.kt (onCreate)

val highlightValue = TypedValue()
theme.resolveAttribute(R.attr.selectableItemBackground, highlightValue, true)

image_dino_1.setBackgroundResource(highlightValue.resourceId)
image_dino_2.setBackgroundResource(highlightValue.resourceId)
image_dino_3.setBackgroundResource(highlightValue.resourceId)
image_dino_4.setBackgroundResource(highlightValue.resourceId)

일반적으로 Android는 현재 포커스가 있는 View의 위쪽, 아래쪽, 왼쪽 또는 오른쪽에 어떤 View가 있는지 꽤 정확하게 판단합니다. 이 앱에서 이 동작이 얼마나 잘 작동하나요? 화살표 키와 Tab 키를 모두 테스트하세요. 화살표 키를 사용하여 메시지 입력란과 Send 버튼 사이를 오가며 탐색해 보세요. 이제 트리케라톱스를 선택하고 Tab 키를 누릅니다. 포커스 위치가 원하는 뷰로 변경되나요?

이 예에서는 결과가 좋지 않습니다(의도됨). 이 같은 입력 반응의 작은 문제라도 사용자에게는 무척 불편할 수 있습니다.

일반적으로 화살표/Tab 키 동작을 수동으로 조정하는 방법은 다음과 같습니다.

화살표 키

android:nextFocusLeft="@id/view_to_left"
android:nextFocusRight="@id/view_to_right"
android:nextFocusUp="@id/view_above"
android:nextFocusDown="@id/view_below"

Tab 키

android:nextFocusForward="@id/next_view"

프로그래매틱 방식으로:

화살표 키

myView.nextFocusLeftId = R.id.view_to_left
myView.nextFocusRightId = R.id.view_to_right
myView.nextFocusTopId = R.id.view_above
myView.nextFocusBottomId = R.id.view_below

Tab 키

myView.nextFocusForwardId - R.id.next_view

이 예에서는 포커스 순서를 다음과 같이 수정할 수 있습니다.

MainActivity.kt

edit_message.nextFocusForwardId = R.id.button_send
edit_message.nextFocusRightId = R.id.button_send
button_send.nextFocusForwardId = R.id.image_dino_1
button_send.nextFocusLeftId = R.id.edit_message
image_dino_2.nextFocusForwardId = R.id.image_dino_3
image_dino_3.nextFocusForwardId = R.id.image_dino_4

이제 공룡을 선택할 수 있습니다. 하지만 화면과 조명 상태, 뷰와 시야에 따라 현재 선택된 항목에 강조표시된 부분이 잘 보이지 않을 수 있습니다. 예를 들어 아래 이미지에서는 회색 위에 회색이 놓이는 것이 기본값입니다.

c0ace19128e548fe.png

사용자에게 좀 더 눈에 잘 띄는 시각적 피드백을 제공하기 위해 다음을 AppTheme의 res/values/styles.xml에 추가하세요.

res/values/styles.xml

<item name="colorControlHighlight">@color/colorAccent</item>

23a53d405efe5602.png

분홍색은 마음에 들지만 위 그림에 나와 있는 것 같은 강조표시는 원하는 것에 비해 지나치게 과할 수 있고 모든 이미지가 정확히 동일한 크기가 아닌 경우 지저분해 보일 수 있습니다. 상태 목록 드로어블을 사용하면 항목이 선택될 때만 표시되는 테두리 드로어블을 만들 수 있습니다.

res/drawable/box_border.xml

<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
   <item android:state_focused="true">
       <shape android:padding="2dp">
           <solid android:color="#FFFFFF" />
           <stroke android:width="1dp" android:color="@color/colorAccent" />
           <padding android:left="2dp" android:top="2dp" android:right="2dp"
               android:bottom="2dp" />
       </shape>
   </item>
</selector>

이제 이전 단계의 highlightValue/setBackgroundResource 줄을 이 새로운 box_border 배경 리소스로 바꿉니다.

MainActivity.kt (onCreate)

image_dino_1.setBackgroundResource(R.drawable.box_border)
image_dino_2.setBackgroundResource(R.drawable.box_border)
image_dino_3.setBackgroundResource(R.drawable.box_border)
image_dino_4.setBackgroundResource(R.drawable.box_border)

77ac1e50cdfbea01.png

631df359631b28bb.png

키보드 사용자는 일반적인 Ctrl 기반 단축키가 작동할 것으로 예상합니다. 이제 앱에 실행취소(Ctrl+Z) 및 다시 실행 (Ctrl+Shift+Z) 단축키를 추가해 보겠습니다.

먼저 간단한 클릭 내역 스택을 만듭니다. 사용자가 5개의 작업을 수행하고 Ctrl+Z를 두 번 눌러 작업 4와 5는 다시 실행 스택에, 작업 1과 2와 3은 실행취소 스택에 있다고 가정해 보겠습니다. 사용자가 다시 Ctrl+Z를 누르면 작업 3은 실행취소 스택에서 다시 실행 스택으로 이동합니다. 그런 다음 Ctrl+Shift+Z 키를 누르면 작업 3은 다시 실행 스택에서 실행취소 스택으로 이동합니다.

9d952ca72a5640d7.png

기본 클래스 상단에서 서로 다른 클릭 작업을 정의하고 ArrayDeque를 사용하여 스택을 만듭니다.

MainActivity.kt

private var undoStack = ArrayDeque<Int>()
private var redoStack = ArrayDeque<Int>()

private val UNDO_MESSAGE_SENT = 1
private val UNDO_DINO_CLICKED = 2

메시지가 전송되거나 공룡이 클릭될 때마다 이 작업을 실행취소 스택에 추가합니다. 새 작업이 발생하면 다시 실행 스택을 지웁니다. 다음과 같이 클릭 리스너를 업데이트합니다.

MainActivity.kt

//In button_send onClick listener
undoStack.push(UNDO_MESSAGE_SENT)
redoStack.clear()

...

//In ImageOnClickListener
undoStack.push(UNDO_DINO_CLICKED)
redoStack.clear()

이제 실제로 단축키를 매핑합니다. Ctrl+ 명령어 지원과 Android O 이상의 경우 Alt+ 및 Shift+ 명령어 지원을 dispatchKeyShortcutEvent를 사용하여 추가할 수 있습니다.

MainActivity.kt (dispatchKeyShortcutEvent)

override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
    if (event.getKeyCode() == KeyEvent.KEYCODE_Z) {
        // Undo action
        return true
    }
    return super.dispatchKeyShortcutEvent(event)
}

이 경우에는 좀 까다롭게 행동해 보겠습니다. hasModifiers를 사용해, 집요하게 Alt+Z 또는 Shift+Z가 아닌 Ctrl+Z만 콜백을 트리거할 수 있게 만듭니다. 아래에 스택 실행취소 작업이 채워집니다.

MainActivity.kt (dispatchKeyShortcutEvent)

override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
    // Ctrl-z == Undo
    if (event.keyCode == KeyEvent.KEYCODE_Z && event.hasModifiers(KeyEvent.META_CTRL_ON)) {
        val lastAction = undoStack.poll()
        if (null != lastAction) {
            redoStack.push(lastAction)

            when (lastAction) {
                UNDO_MESSAGE_SENT -> {
                    messagesSent--
                    text_messages_sent.text = (Integer.toString(messagesSent))
                }

                UNDO_DINO_CLICKED -> {
                    dinosClicked--
                    text_dinos_clicked.text = Integer.toString(dinosClicked)
                }

                else -> Log.d("OptimizedChromeOS", "Error on Ctrl-z: Unknown Action")
            }

            return true
        }
    }
    return super.dispatchKeyShortcutEvent(event)
}

테스트해 보세요. 제대로 작동하나요? 이제 OR을 특수키 플래그와 함께 사용하여 Ctrl+Shift+Z를 추가합니다.

MainActivity.kt (dispatchKeyShortcutEvent)

// Ctrl-Shift-z == Redo
if (event.keyCode == KeyEvent.KEYCODE_Z &&
    event.hasModifiers(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON)) {
    val prevAction = redoStack.poll()
    if (null != prevAction) {
        undoStack.push(prevAction)

        when (prevAction) {
            UNDO_MESSAGE_SENT -> {
                messagesSent++
                text_messages_sent.text = (Integer.toString(messagesSent))
            }

            UNDO_DINO_CLICKED -> {
                dinosClicked++
                text_dinos_clicked.text = Integer.toString(dinosClicked)
            }

            else -> Log.d("OptimizedChromeOS", "Error on Ctrl-Shift-z: Unknown Action")
        }

        return true
    }
}

대부분의 인터페이스에서 사용자는 마우스 오른쪽 버튼을 클릭하거나 트랙패드를 두 번 탭하면 컨텍스트 메뉴가 표시된다고 여깁니다. 이 앱에서는 사용자가 친한 친구에게 멋진 공룡 사진을 보낼 수 있도록 이 컨텍스트 메뉴를 제공해 보겠습니다.

8b8c4a377f5e743b.png

컨텍스트 메뉴를 생성하면 마우스 오른쪽 버튼 클릭 기능이 자동으로 포함됩니다. 대부분의 경우 이 기능만 있으면 됩니다. 이 기능 설정은 세 가지 부분으로 구성됩니다.

이 뷰에 컨텍스트 메뉴가 있음을 UI가 인식할 수 있도록 하기

컨텍스트 메뉴를 사용할 각 뷰에서 registerForContextMenu를 사용합니다(이 경우에는 이미지 4개에서 사용함).

MainActivity.kt

registerForContextMenu(image_dino_1)
registerForContextMenu(image_dino_2)
registerForContextMenu(image_dino_3)
registerForContextMenu(image_dino_4)

컨텍스트 메뉴 모양 정의

필요한 모든 컨텍스트 옵션이 포함된 메뉴를 XML에서 디자인합니다. 이렇게 하려면 'Share'를 추가하기만 하면 됩니다.

res/menu/context_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menu_item_share_dino"
        android:icon="@android:drawable/ic_menu_share"
        android:title="@string/menu_share" />
</menu>

그런 다음 기본 활동 클래스에서 onCreateContextMenu를 재정의하고 XML 파일에 전달합니다.

MainActivity.kt

override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
    super.onCreateContextMenu(menu, v, menuInfo)
    val inflater = menuInflater
    inflater.inflate(R.menu.context_menu, menu)
}

특정 항목이 선택될 경우 수행할 작업 정의

마지막으로 onContextItemSelected를 재정의하여 수행할 작업을 정의합니다. 여기에서 이미지가 제대로 공유되었음을 사용자에게 알릴 수 있는 간단한 Snackbar를 표시합니다.

MainActivity.kt

override fun onContextItemSelected(item: MenuItem): Boolean {
    if (R.id.menu_item_share_dino == item.itemId) {
        Snackbar.make(findViewById(android.R.id.content),
            getString(R.string.menu_shared_message), Snackbar.LENGTH_SHORT).show()
        return true
    } else {
        return super.onContextItemSelected(item)
    }
}

테스트해 보세요. 이미지를 마우스 오른쪽 버튼으로 클릭하면 컨텍스트 메뉴가 표시될 것입니다.

MainActivity.kt

myView.setOnContextClickListener {
    // Display right-click options
    true
}

사용자가 쉽게 UI 작동 방식을 이해하도록 돕거나 추가 정보를 제공하려면 마우스 오버로 표시되는 도움말 텍스트를 추가하면 됩니다.

17639493329a9d1a.png

setTootltipText() 메서드를 사용하여 공룡 이름과 함께 각 사진에 관한 도움말을 추가합니다.

MainActivity.kt

// Add dino tooltips
TooltipCompat.setTooltipText(image_dino_1, getString(R.string.name_dino_hadrosaur))
TooltipCompat.setTooltipText(image_dino_2, getString(R.string.name_dino_triceratops))
TooltipCompat.setTooltipText(image_dino_3, getString(R.string.name_dino_nodosaur))
TooltipCompat.setTooltipText(image_dino_4, getString(R.string.name_dino_afrovenator))

포인팅 기기의 마우스가 특정 뷰 위에 있을 때 해당 뷰에 시각적 반응 효과를 추가하는 것이 유용할 수 있습니다.

이러한 반응을 추가하려면 아래의 코드를 사용하여 Send 버튼 위에 마우스를 가져가면 이 버튼이 녹색으로 바뀌도록 합니다.

MainActivity.kt (onCreate)

button_send.setOnHoverListener(View.OnHoverListener { v, event ->
    val action = event.actionMasked

    when (action) {
        ACTION_HOVER_ENTER -> {
            val buttonColorStateList = ColorStateList(
                arrayOf(intArrayOf()),
                intArrayOf(Color.argb(127, 0, 255, 0))
            )
            button_send.setBackgroundTintList(buttonColorStateList)
            return@OnHoverListener true
        }

        ACTION_HOVER_EXIT -> {
            button_send.setBackgroundTintList(null)
            return@OnHoverListener true
        }
    }

    false
})

마우스 오버 효과를 하나 더 추가해 보겠습니다. 텍스트가 드래그 가능함을 사용자에게 알리기 위해 드래그 가능한 TextView와 연결된 배경 이미지를 변경해 보겠습니다.

MainActivity.kt (onCreate)

text_drag.setOnHoverListener(View.OnHoverListener { v, event ->
    val action = event.actionMasked

    when (action) {
        ACTION_HOVER_ENTER -> {
            text_drag.setBackgroundResource(R.drawable.hand)
            return@OnHoverListener true
        }

        ACTION_HOVER_EXIT -> {
            text_drag.setBackgroundResource(0)
            return@OnHoverListener true
        }
    }

    false
})

테스트해 보세요. 'Drag Me!' 텍스트에 마우스를 가져가면 박수 그래픽이 표시될 것입니다. 이 눈에 띄는 반응을 통해 촉각을 이용하는 사용자 환경이 제공됩니다.

자세한 내용은 View.OnHoverListenerMotionEvent 문서를 참고하세요.

데스크톱 환경에서는 특히 Chrome OS의 파일 관리자를 통해 앱에 항목을 드래그 앤 드롭하는 것이 자연스럽습니다. 이 단계에서는 파일 또는 일반 텍스트 항목을 수신할 수 있는 드롭 타겟을 설정해 보겠습니다. 드래그 가능한 항목은 Codelab의 다음 섹션에서 구현할 것입니다.

cfbc5c9d8d28e5c5.gif

먼저 빈 OnDragListener를 만듭니다. 코딩을 시작하기 전에 구조를 먼저 살펴보세요.

MainActivity.kt

protected inner class DropTargetListener(private val activity: AppCompatActivity
) : View.OnDragListener {
    override fun onDrag(v: View, event: DragEvent): Boolean {
        val action = event.action

        when (action) {
            DragEvent.ACTION_DRAG_STARTED -> {
                    return true
            }

            DragEvent.ACTION_DRAG_ENTERED -> {
                return true
            }

            DragEvent.ACTION_DRAG_EXITED -> {
                return true
            }

            DragEvent.ACTION_DRAG_ENDED -> {
                return true
            }

            DragEvent.ACTION_DROP -> {
                return true
            }

            else -> {
                Log.d("OptimizedChromeOS", "Unknown action type received by DropTargetListener.")
                return false
            }
        }
    }
}

onDrag() 메서드는 서로 다른 몇몇 드래그 이벤트가 발생할 때마다 호출됩니다. 드래그를 시작하거나, 드롭 영역 위로 마우스를 가져가거나, 항목이 실제로 드롭될 때와 같은 이벤트가 있을 수 있습니다. 다음은 서로 다른 드래그 이벤트에 관한 요약입니다.

  • ACTION_DRAG_STARTED는 항목이 드래그될 때 트리거됩니다. 타겟은 수신할 수 있는 유효한 항목을 찾고, 그것이 준비된 타겟임을 시각적으로 나타내야 합니다.
  • ACTION_DRAG_ENTEREDACTION_DRAG_EXITED는 항목이 드래그되고 있을 때와 드롭 영역에 들어가거나 그곳에서 나올 때 트리거됩니다. 개발자는 이 드래그 이벤트로 항목을 드롭할 수 있음을 사용자에게 알리는 시각적 반응을 제공해야 합니다.
  • ACTION_DROP은 항목이 실제로 드롭되면 트리거됩니다. 여기에서 항목을 처리하세요.
  • ACTION_DRAG_ENDED는 드롭이 제대로 드롭되거나 취소되면 트리거됩니다. UI를 일반 상태로 반환하세요.

ACTION_DRAG_STARTED

이 이벤트는 드래그가 시작될 때마다 트리거됩니다. 타겟이 특정 항목을 수신할 수 있는지(true를 반환) 아니면 수신할 수 없는지(false를 반환)를 여기에 나타내고, 이를 시각적으로 사용자에게 알리세요. 드래그 이벤트에는 드래그되는 항목의 정보가 포함된 ClipDescription이 들어 있습니다.

이 드래그 리스너가 항목을 수신할 수 있는지 확인하려면 항목의 MIME 유형을 살펴보세요. 이 예에서는 배경 조명 색조를 녹색으로 조정하여 타겟이 유효한 것임을 나타냅니다.

MainActivity.kt

DragEvent.ACTION_DRAG_STARTED -> {
    // Limit the types of items that can be received
    if (event.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) ||
        event.clipDescription.hasMimeType("application/x-arc-uri-list")) {

        // Greenify background colour so user knows this is a target
        v.setBackgroundColor(Color.argb(55, 0, 255, 0))
        return true
    }

    // If the dragged item is of an unrecognized type, indicate this is not a valid target
    return false
}

ENTERED, EXITED 및 ENDED

ENTERED 및 EXITED는 시각적/햅틱 반응 로직이 위치하는 곳입니다. 이 예에서는 항목을 타겟 영역 위에 가져갔을 때 드롭할 수 있음을 사용자에게 알리기 위해 녹색을 더 짙게 만드세요. ENDED에서는 UI를 드래그 앤 드롭이 아닌 일반 상태로 재설정하세요.

MainActivity.kt

DragEvent.ACTION_DRAG_ENTERED -> {
    // Increase green background colour when item is over top of target
    v.setBackgroundColor(Color.argb(150, 0, 255, 0))
    return true
}

DragEvent.ACTION_DRAG_EXITED -> {
    // Less intense green background colour when item not over target
    v.setBackgroundColor(Color.argb(55, 0, 255, 0))
    return true
}

DragEvent.ACTION_DRAG_ENDED -> {
    // Restore background colour to transparent
    v.setBackgroundColor(Color.argb(0, 255, 255, 255))
    return true
}

ACTION_DROP

항목이 실제로 타겟에 드롭되면 발생하는 이벤트입니다. 처리 과정이 완료되는 위치이기도 합니다.

참고: Chrome OS 파일에는 ContentResolver를 사용하여 액세스해야 합니다.

이 데모에서 타겟은 일반 텍스트 객체나 파일을 수신할 수 있습니다. 일반 텍스트의 경우에는 TextView에 텍스트를 표시하세요. 파일의 경우에는 처음 200자(영문 기준)를 복사하여 표시하세요.

MainActivity.kt

DragEvent.ACTION_DROP -> {
    requestDragAndDropPermissions(event) // Allow items from other applications
    val item = event.clipData.getItemAt(0)
    val textTarget = v as TextView

    if (event.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
        // If this is a text item, simply display it in a new TextView.
        textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
        textTarget.text = item.text
        // In STEP 10, replace line above with this
        // dinoModel.setDropText(item.text.toString())
    } else if (event.clipDescription.hasMimeType("application/x-arc-uri-list")) {
        // If a file, read the first 200 characters and output them in a new TextView.

        // Note the use of ContentResolver to resolve the ChromeOS content URI.
        val contentUri = item.uri
        val parcelFileDescriptor: ParcelFileDescriptor?
        try {
            parcelFileDescriptor = contentResolver.openFileDescriptor(contentUri, "r")
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
            Log.e("OptimizedChromeOS", "Error receiving file: File not found.")
            return false
        }

        if (parcelFileDescriptor == null) {
            textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
            textTarget.text = "Error: could not load file: " + contentUri.toString()
            // In STEP 10, replace line above with this
            // dinoModel.setDropText("Error: could not load file: " + contentUri.toString())
            return false
        }

        val fileDescriptor = parcelFileDescriptor.fileDescriptor

        val MAX_LENGTH = 5000
        val bytes = ByteArray(MAX_LENGTH)

        try {
            val `in` = FileInputStream(fileDescriptor)
            try {
                `in`.read(bytes, 0, MAX_LENGTH)
            } finally {
                `in`.close()
            }
        } catch (ex: Exception) {
        }

        val contents = String(bytes)

        val CHARS_TO_READ = 200
        val content_length = if (contents.length > CHARS_TO_READ) CHARS_TO_READ else 0

        textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f)
        textTarget.text = contents.substring(0, content_length)
        // In STEP 10, replace line above with this
        // dinoModel.setDropText(contents.substring(0, content_length))
    } else {
        return false
    }
    return true
}

OnDragListener

이제 DropTargetListener가 설정되었으므로 드롭된 항목을 수신할 뷰에 이 요소를 연결합니다.

MainActivity.kt

text_drop.setOnDragListener(DropTargetListener(this))

테스트해 보세요. Chrome OS 파일 관리자에서 파일을 드래그해야 한다는 점을 기억하세요. Chrome OS 텍스트 편집기를 사용하여 텍스트 파일을 만들거나 인터넷에서 이미지 파일을 다운로드할 수 있습니다.

이제 앱에서 드래그 가능한 항목을 설정해 보겠습니다. 드래그 프로세스는 보통 뷰를 길게 누르면 트리거됩니다. 항목을 드래그할 수 있음을 나타내기 위해 LongClickListener를 생성합니다. 이 요소는 전송 중인 데이터를 시스템에 제공하고 어떤 유형의 데이터인지를 나타냅니다. 또한 이 요소에서 드래그 시의 항목 모양을 구성할 수도 있습니다.

TextView에서 문자열을 끌어오는 일반 텍스트 드래그 항목을 설정합니다. 콘텐츠 MIME 유형을 ClipDescription.MIMETYPE_TEXT_PLAIN으로 설정합니다.

드래그 시의 시각적 모양은 내장 DragShadowBuilder를 사용하여 표준 반투명 드래그 모양으로 지정합니다. 좀 더 복잡한 예를 확인하려면 문서에서 드래그 시작을 확인하세요.

이 항목을 다른 앱에 드래그할 수 있음을 나타내려면 DRAG_FLAG_GLOBAL 플래그를 설정해야 합니다.

MainActivity.kt

protected inner class TextViewLongClickListener : View.OnLongClickListener {
    override fun onLongClick(v: View): Boolean {
        val thisTextView = v as TextView
        val dragContent = "Dragged Text: " + thisTextView.text

        //Set the drag content and type
        val item = ClipData.Item(dragContent)
        val dragData = ClipData(dragContent, arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), item)

        //Set the visual look of the dragged object
        //Can be extended and customized. We use the default here.
        val dragShadow = View.DragShadowBuilder(v)

        // Starts the drag, note: global flag allows for cross-application drag
        v.startDragAndDrop(dragData, dragShadow, null, View.DRAG_FLAG_GLOBAL)

        return false
    }
}

이제 드래그 가능한 TextViewLongClickListener를 추가합니다.

MainActivity.kt (onCreate)

text_drag.setOnLongClickListener(TextViewLongClickListener())

직접 해 보세요. TextView에서 텍스트를 드래그할 수 있나요?

이제 앱이 키보드 지원, 마우스 지원, 공룡 등으로 꽤 괜찮아 보입니다. 하지만 데스크톱 환경에서 사용자는 앱 크기 조절, 최대화, 최대화 해제, 태블릿 모드로 변경, 방향 변경 등 다양한 작업을 자주 실행합니다. 이 경우 드롭된 항목, 전송된 메시지 카운터, 클릭 카운터는 어떻게 되나요?

활동 수명 주기는 Android 앱을 만들 때 이해하는 것이 중요합니다. 앱이 복잡해질수록 수명 주기 상태를 관리하기가 어려워질 수 있습니다. 다행히도 아키텍처 구성요소를 사용하여 수명 주기 문제를 확실히 처리할 수 있습니다. 이 Codelab에서는 ViewModelLiveData를 사용하여 앱 상태를 보존하는 방법을 중점적으로 살펴보겠습니다.

ViewModel은 수명 주기 변화 전반에서 UI 관련 데이터를 관리하는 데 도움이 됩니다. LiveData는 관찰자로 기능하며 UI 요소를 자동으로 업데이트합니다.

이 앱에서는 다음 데이터를 추적한다고 가정하겠습니다.

  • 전송된 메시지 카운터(ViewModel, LiveData)
  • 이미지 클릭 카운터(ViewModel, LiveData)
  • 현재 드롭 타겟 텍스트(ViewModel, LiveData)
  • 실행취소/다시 실행 스택(ViewModel)

이를 설정하는 ViewModel 클래스의 코드를 살펴봅니다. 기본적으로 이 코드에는 싱글톤 패턴을 사용하는 getter와 setter가 포함됩니다.

DinoViewModel.kt

class DinoViewModel : ViewModel() {
    private val undoStack = ArrayDeque<Int>()
    private val redoStack = ArrayDeque<Int>()

    private val messagesSent = MutableLiveData<Int>().apply { value = 0 }
    private val dinosClicked = MutableLiveData<Int>().apply { value = 0 }
    private val dropText = MutableLiveData<String>().apply { value = "Drop Things Here!" }

    fun getUndoStack(): ArrayDeque<Int> {
        return undoStack
    }

    fun getRedoStack(): ArrayDeque<Int> {
        return redoStack
    }

    fun getDinosClicked(): LiveData<Int> {
        return dinosClicked
    }

    fun getDinosClickedInt(): Int {
        return dinosClicked.value ?: 0
    }

    fun setDinosClicked(newNumClicks: Int): LiveData<Int> {
        dinosClicked.value = newNumClicks
        return dinosClicked
    }

    fun getMessagesSent(): LiveData<Int> {
        return messagesSent
    }

    fun getMessagesSentInt(): Int {
        return messagesSent.value ?: 0
    }

    fun setMessagesSent(newMessagesSent: Int): LiveData<Int> {
        messagesSent.value = newMessagesSent
        return messagesSent
    }

    fun getDropText(): LiveData<String> {
        return dropText
    }

    fun setDropText(newDropText: String): LiveData<String> {
        dropText.value = newDropText
        return dropText
    }
}

기본 활동에서 ViewModelProvider를 사용하여 ViewModel를 가져옵니다. 그러면 모든 수명 주기 매직이 수행됩니다. 예를 들어 실행취소 스택과 다시 실행 스택은 크기 조절, 방향, 레이아웃 변경 중에 자동으로 상태를 유지합니다.

MainActivity.kt (onCreate)

// Get the persistent ViewModel
dinoModel = ViewModelProviders.of(this).get(DinoViewModel::class.java)

// Restore our stacks
undoStack = dinoModel.getUndoStack()
redoStack = dinoModel.getRedoStack()

LiveData 변수의 경우 Observer 객체를 만들고 연결해 변수가 변경될 때 어떻게 변경되는지 UI에 알립니다.

MainActivity.kt (onCreate)

// Set up data observers
dinoModel.getMessagesSent().observe(this, androidx.lifecycle.Observer { newCount ->
    text_messages_sent.setText(Integer.toString(newCount))
})

dinoModel.getDinosClicked().observe(this, androidx.lifecycle.Observer { newCount ->
    text_dinos_clicked.setText(Integer.toString(newCount))
})

dinoModel.getDropText().observe(this, androidx.lifecycle.Observer { newString ->
    text_drop.text = newString
})

이러한 관찰자가 배치되면 ViewModel 변수 데이터만 수정하도록 모든 클릭 콜백의 코드를 단순화할 수 있습니다.

아래 코드는 TextView 객체를 직접 조정할 필요가 없음을 보여줍니다. LiveData 관찰자가 있는 모든 UI 요소가 자동으로 업데이트되기 때문입니다.

MainActivity.kt

internal inner class SendButtonOnClickListener(private val sentCounter: TextView) : View.OnClickListener {
    override fun onClick(v: View?) {
        undoStack.push(UNDO_MESSAGE_SENT)
        redoStack.clear()
        edit_message.getText().clear()

        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() + 1)
    }
}

internal inner class ImageOnClickListener(private val clickCounter: TextView) : View.OnClickListener {
    override fun onClick(v: View) {
        undoStack.push(UNDO_DINO_CLICKED)
        redoStack.clear()

        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() + 1)
    }
}

마지막으로, UI를 직접 조정하는 대신 실행취소/다시 실행 명령어를 업데이트하여 ViewModel과 LiveData를 사용합니다.

MainActivity.kt

when (lastAction) {
    UNDO_MESSAGE_SENT -> {
        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() - 1)
    }

    UNDO_DINO_CLICKED -> {
        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() - 1)
    }

    else -> Log.d("OptimizedChromeOS", "Error on Ctrl-z: Unknown Action")
}

...

when (prevAction) {
    UNDO_MESSAGE_SENT -> {
        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() + 1)
    }

    UNDO_DINO_CLICKED -> {
        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() + 1)
    }

    else -> Log.d("OptimizedChromeOS", "Error on Ctrl-Shift-z: Unknown Action")
}

직접 해 보세요. 이제 크기가 어떻게 조절되나요? 아키텍처 구성요소가 마음에 드나요?

아키텍처 구성요소에 관한 자세한 내용은 Android 수명 주기 Codelab을 확인하세요. 이 블로그 게시물을 참고하면 ViewModel과 onSavedStateInstanceState의 작동 및 상호작용 방식을 쉽게 이해할 수 있습니다.

축하합니다. 수고하셨습니다. 지금까지 Chrome OS용 Android 앱을 최적화할 때 개발자가 가장 흔하게 직면하는 문제에 관해 알아보았습니다.

52240dc3e68f7af8.png

샘플 소스 코드

GitHub에서 저장소를 클론합니다.

git clone https://github.com/googlecodelabs/optimized-for-chromeos

또는 저장소를 Zip 파일로 다운로드합니다.

Zip 파일 다운로드