Chrome OS용 Android 앱 최적화

Chrome OS용 Android 앱 최적화

이 Codelab 정보

subject최종 업데이트: 2월 24, 2021
account_circle작성자: 에밀리 로버츠

1. 소개

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

f60cd3eb5b298d5d.png

빌드할 프로그램

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

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

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

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

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

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

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

52240dc3e68f7af8.png

과정 내용

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

필요한 항목

2. 시작하기 - 손상된 앱

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

3. Enter 키

키보드를 사용하여 비밀 메시지를 몇 개 입력하면 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
})

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

4. 화살표 키 탐색

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

뷰를 포커스 가능으로 설정하면 화살표와 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

5. 선택된 항목 강조표시 색상

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

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

6. Ctrl 기반 단축키

키보드 사용자는 일반적인 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
   
}
}

7. 마우스 오른쪽 버튼 클릭

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

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
}

8. 도움말

사용자가 쉽게 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))

9. 마우스 오버 효과

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

이러한 반응을 추가하려면 아래의 코드를 사용하여 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 문서를 참고하세요.

10. 드래그 앤 드롭(드롭 지원 추가)

데스크톱 환경에서는 특히 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 텍스트 편집기를 사용하여 텍스트 파일을 만들거나 인터넷에서 이미지 파일을 다운로드할 수 있습니다.

11. 드래그 앤 드롭(드래그 지원 추가)

이제 앱에서 드래그 가능한 항목을 설정해 보겠습니다. 드래그 프로세스는 보통 뷰를 길게 누르면 트리거됩니다. 항목을 드래그할 수 있음을 나타내기 위해 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에서 텍스트를 드래그할 수 있나요?

12. 아키텍처 구성요소로 상태 보존

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

활동 수명 주기는 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의 작동 및 상호작용 방식을 쉽게 이해할 수 있습니다.

13. 축하합니다.

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

52240dc3e68f7af8.png

샘플 소스 코드

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

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

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

Zip 파일 다운로드