Chromebook에서 Android 앱을 실행할 수 있어, 이제 사용자는 방대한 앱 생태계와 다양한 새로운 기능을 사용할 수 있습니다. 이는 개발자에게 좋은 소식이지만 사용성 기대치를 충족하고 뛰어난 사용자 환경을 제공하려면 특정 앱 최적화가 필요합니다. 이 코드랩에서는 최적화의 가장 일반적인 경우를 안내합니다.
빌드할 프로그램
Chrome OS와 관련된 권장사항과 최적화를 보여줄 작동하는 Android 앱을 빌드합니다. 이 앱에는 아래의 기능이 있습니다.
다음을 포함한 키보드 입력 처리
- Enter 키
- 화살표 키
- Ctrl 및 Ctrl+Shift 단축키
- 현재 선택한 항목의 시각적 피드백
다음을 포함한 마우스 입력 처리
- 마우스 오른쪽 버튼 클릭
- 마우스 오버 효과
- 도움말
- 드래그 앤 드롭
아키텍처 구성요소를 사용하여 아래 작업 실행
- 상태 유지
- 자동으로 UI 업데이트
과정 내용
- Chrome OS에서 키보드와 마우스 입력을 처리하기 위한 권장사항
- Chrome OS 관련 최적화
- ViewModel 및 LiveData 아키텍처 구성요소의 기본 구현
필요한 항목
- 기본 Android 앱을 만들 수 있는 지식
- Android 스튜디오가 설치된 Chromebook 또는 ADB가 설정된 Chromebook 및 Android 스튜디오가 설치된 별도의 개발 컴퓨터
GitHub에서 저장소를 클론합니다.
git clone https://github.com/googlecodelabs/optimized-for-chromeos
또는 저장소의 ZIP 파일을 다운로드하고 압축을 풉니다.
프로젝트 가져오기
- Android 스튜디오 열기
- Import Project 또는 File > New > Import Project 선택
- 프로젝트를 클론했거나 추출한 위치로 이동
- 프로젝트 optimized-for-chromeos 가져오기
- start 및 complete라는 두 가지 모듈이 있음
앱 사용해 보기
- start 모듈 빌드 및 실행
- 트랙패드만 사용하여 시작
- 공룡 클릭
- 비밀 메시지 보내기
- 'Drag Me' 텍스트를 드래그하거나 파일을 'Drop Things Here' 영역에 드래그해 보기
- 키보드를 사용하여 메시지를 탐색하고 전송해 보기
- 태블릿 모드에서 앱 사용해 보기
- 기기를 회전하거나 창 크기를 조절해 보기
어떻게 생각하세요?
이 앱은 매우 기본적이고 잘못된 것처럼 보이는 부분을 쉽게 해결할 수 있지만 사용자 환경이 매우 나쁩니다. 수정해 보겠습니다.
키보드를 사용하여 비밀 메시지를 몇 개 입력하면 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 키를 통해 쉽게 뷰를 탐색할 수 있습니다.
레이아웃 파일을 검토하고 Button
및 ImageView
태그를 확인합니다. 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
이제 공룡을 선택할 수 있습니다. 하지만 화면과 조명 상태, 뷰와 시야에 따라 현재 선택된 항목에 강조표시된 부분이 잘 보이지 않을 수 있습니다. 예를 들어 아래 이미지에서는 회색 위에 회색이 놓이는 것이 기본값입니다.
사용자에게 좀 더 눈에 잘 띄는 시각적 피드백을 제공하기 위해 다음을 AppTheme의 res/values/styles.xml에 추가하세요.
res/values/styles.xml
<item name="colorControlHighlight">@color/colorAccent</item>
분홍색은 마음에 들지만 위 그림에 나와 있는 것 같은 강조표시는 원하는 것에 비해 지나치게 과할 수 있고 모든 이미지가 정확히 동일한 크기가 아닌 경우 지저분해 보일 수 있습니다. 상태 목록 드로어블을 사용하면 항목이 선택될 때만 표시되는 테두리 드로어블을 만들 수 있습니다.
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)
키보드 사용자는 일반적인 Ctrl 기반 단축키가 작동할 것으로 예상합니다. 이제 앱에 실행취소(Ctrl+Z) 및 다시 실행 (Ctrl+Shift+Z) 단축키를 추가해 보겠습니다.
먼저 간단한 클릭 내역 스택을 만듭니다. 사용자가 5개의 작업을 수행하고 Ctrl+Z를 두 번 눌러 작업 4와 5는 다시 실행 스택에, 작업 1과 2와 3은 실행취소 스택에 있다고 가정해 보겠습니다. 사용자가 다시 Ctrl+Z를 누르면 작업 3은 실행취소 스택에서 다시 실행 스택으로 이동합니다. 그런 다음 Ctrl+Shift+Z 키를 누르면 작업 3은 다시 실행 스택에서 실행취소 스택으로 이동합니다.
기본 클래스 상단에서 서로 다른 클릭 작업을 정의하고 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
}
}
대부분의 인터페이스에서 사용자는 마우스 오른쪽 버튼을 클릭하거나 트랙패드를 두 번 탭하면 컨텍스트 메뉴가 표시된다고 여깁니다. 이 앱에서는 사용자가 친한 친구에게 멋진 공룡 사진을 보낼 수 있도록 이 컨텍스트 메뉴를 제공해 보겠습니다.
컨텍스트 메뉴를 생성하면 마우스 오른쪽 버튼 클릭 기능이 자동으로 포함됩니다. 대부분의 경우 이 기능만 있으면 됩니다. 이 기능 설정은 세 가지 부분으로 구성됩니다.
이 뷰에 컨텍스트 메뉴가 있음을 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 작동 방식을 이해하도록 돕거나 추가 정보를 제공하려면 마우스 오버로 표시되는 도움말 텍스트를 추가하면 됩니다.
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.OnHoverListener
및 MotionEvent
문서를 참고하세요.
데스크톱 환경에서는 특히 Chrome OS의 파일 관리자를 통해 앱에 항목을 드래그 앤 드롭하는 것이 자연스럽습니다. 이 단계에서는 파일 또는 일반 텍스트 항목을 수신할 수 있는 드롭 타겟을 설정해 보겠습니다. 드래그 가능한 항목은 Codelab의 다음 섹션에서 구현할 것입니다.
먼저 빈 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_ENTERED 및 ACTION_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
}
}
이제 드래그 가능한 TextView
에 LongClickListener
를 추가합니다.
MainActivity.kt (onCreate)
text_drag.setOnLongClickListener(TextViewLongClickListener())
직접 해 보세요. TextView에서 텍스트를 드래그할 수 있나요?
이제 앱이 키보드 지원, 마우스 지원, 공룡 등으로 꽤 괜찮아 보입니다. 하지만 데스크톱 환경에서 사용자는 앱 크기 조절, 최대화, 최대화 해제, 태블릿 모드로 변경, 방향 변경 등 다양한 작업을 자주 실행합니다. 이 경우 드롭된 항목, 전송된 메시지 카운터, 클릭 카운터는 어떻게 되나요?
활동 수명 주기는 Android 앱을 만들 때 이해하는 것이 중요합니다. 앱이 복잡해질수록 수명 주기 상태를 관리하기가 어려워질 수 있습니다. 다행히도 아키텍처 구성요소를 사용하여 수명 주기 문제를 확실히 처리할 수 있습니다. 이 Codelab에서는 ViewModel 및 LiveData를 사용하여 앱 상태를 보존하는 방법을 중점적으로 살펴보겠습니다.
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 앱을 최적화할 때 개발자가 가장 흔하게 직면하는 문제에 관해 알아보았습니다.
샘플 소스 코드
GitHub에서 저장소를 클론합니다.
git clone https://github.com/googlecodelabs/optimized-for-chromeos
또는 저장소를 Zip 파일로 다운로드합니다.