2023년 1월 22일 일요일

Kotlin Tip

Kotlin Tip

Kotlin Tip 모음

Kotlin DSL

Kotlin은 DSL 은 아래의 자료를 참고

Kotlin Koin

Kotlin은 Koin 은 아래의 자료를 참고

Kotlin Ktlint

Kotlin은 ktlint 은 아래의 자료를 참고
Kotlin은 ktlint&git hook 연동은 은 아래의 자료를 참고

Collection

map: List 에서 특정 필드만 다시 List로 바꿔주기

Collection 클래스에는 map 이라는 기능이 있어서 소스 List의 각 요소에 변환(transform) 처리한 결과값을 List형태로 돌려 받을수 있다.
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/map.html

data class hehe ( val id: Long = -1L, val name: String)

myData.map { it.id } ==> List<Long>
myData.map { it.name } ==> List<String>

General

Sealed Interface를 사용해야 되는 이유

Sealed 클래스는 프로그램 표준이 되어도 좋다고 생각할 정도로 유용하다.
Sealed Class와 같은 형태로 선언하고 사용하는데 Sealed Interface를 사용하면 사용되는 메모리를 절반으로 줄일수 있다고 한다.
Sealed 를 사용할때는 인터페이스로 가능하다면 인터페이스우선으로 설계하자.
https://medium.com/@omurkumru/sealed-classes-sealed-interfaces-and-enum-classes-in-kotlin-8df3abacbe4c

Min,Max를 사용하지 않고 CoerceIn을 사용하여 범위내의 수로 제한하기

coerceIn을 사용하면 Min, Max를 사용하지 않고도 깔끔하게 값을 일정범위내로 제한할수있다.
단, 음수일때는 소괄호로 묶어줘야 한다. 이거…에러가 될 소지가 높으니 조심.!!

13.coerceIn(0,  10)  //10  또는 coerceIn(0..10) 처럼범위함수 사용가능
(-13).coerceIn(0,  10)  //0  
4.coerceIn(0,  10)  //4
'''
그밖에 아래의 두가지 함수가 있다.
coerceAtLeast(리시버의 값이 지정값이하일때는지정값으로 무조건 정함) 
coerceAtMost(리시버의 값이 지정값이상일때는지정값으로 무조건 정함) 

### until 을 활용하여 코드를 깔끔하게
val ERROR_AA = 1 until 5
val ERROR_BB = 10 until 50
--> in AA -> { , in BB -> {

### 단일표현식함수
```kotlin
//개선전
fun double(x: Int): Int { 
    return x * 2
}

//개선후
fun double(x: Int): Int = x * 2

범위함수

범위가 지정된 함수에는 let , run , with , apply 및 also 의 다섯 가지 유형이 있습니다 .

//개선전
var rectangleB = Rectangle()
rectangleB.length = 4
rectangleB.breadth = 5
rectangleB.color = 0xFAFAFA
rectangleB.padding = 2

//개선후
var rectangleB = Rectangle().apply {
    length = 4
    breadth = 5
    color = 0xFAFAFA
    padding = 2
}

컬렉션생성자

kotlin은 쉽고 빠르게 집합을 생성할 수 있는 표준 라이브러리 기능을 제공합니다…

//개선전
val listA = ArrayList<String>()
listA.add("one")
listA.add("two")
listA.add("three")
listA.add("four")

//개선후
val listB = mutableListOf("one", "two", "three", "four")

집합연산자

kotlin에서 제공하는 집합 연산자를 사용하면 편리합니다.
kotlin 프로젝트에서 _Collections.kt 파일을 검색하고 열어 더 많은 컬렉션 연산자를 볼 수 있습니다. 또한 컬렉션에서 작업할 때 이 파일로 이동하여 적합한 연산자가 있는지 확인하는 것이 좋습니다

//개선전
var firstBigLengthElementA = ""
for (e in listA) {
    if (e.length > 3) {
        firstBigLengthElementA = e
        break
    }
}

//개선후
var firstBigLengthElementB = listA.first { it.length > 3 }

지연 속성/초기화

지연 속성 lazy는 kotlin에서 제공하는 델리게이트 속성 중 하나로, kotlin 델리게이트 속성에 대해서는 다음 글에서 자세히 설명하도록 하겠습니다.

lazy()는 람다를 받아 속성의 대리자로 사용할 수 있는 Lazy 인스턴스를 반환하는 함수입니다.

대리자 속성 + 지연 메서드를 사용하여 속성의 지연된 초기화를 실현할 수 있음을 간단히 이해할 수 있습니다.


data class Person(
    val name: String,
    val age: String,
    val sex: String
) {
    init {
        println("person onCreate")
    }
}


class Test {
    //개선전
    val personA: Person = Person("name", "age", "girl")

    //개선후
    val personB: Person by lazy {
        println("lazy create person")
        Person("name", "age", "girl")
    }
    
    init{
        println("Test class onInit")
    }

    fun test() {
        println("A's age is:${personA.age}")
        println("B's age is:${personB.age}")
    }
}



fun main() {
    Test().test()
}

유형 별칭 - typealias

typealias는 기존 유형에 대한 대체 이름을 제공합니다. 유형 이름이 너무 길면 더 짧은 이름을 추가로 도입하고 원래 유형 이름을 새 이름으로 바꿀 수 있습니다.

이 키워드는 복잡한 제네릭 유형 또는 매개변수 정의가 더 많은 함수 유형에 사용할 수 있습니다. 복잡한 유형 정의 행을 매우 짧게 만들고 유형 정의를 사용하는 코드를 훨씬 더 간결하게 만들 수 있습니다.

//개선전
val fileTableA: MutableMap<Long, MutableList<File>>? = null
fun getTestFileTableA(): MutableMap<Long, MutableList<File>>? = fileTableA

//개선후
typealias FileTable<K> = MutableMap<K, MutableList<File>>
val fileTableB: FileTable<Long>? = null
fun getTestFileTableB(): FileTable<Long>? = fileTableB

고차함수로 전략 패턴을 단순하게 구현가능


class Worker(private val strategy: () -> Unit) {
    fun work() {
        println("START")
        strategy.invoke()
        println("END")
    }
}


fun testStrategy() {
    val worker1 = Worker({
        println("Do A Strategy")
    })
    val bStrategy = {
        println("Do B Strategy")
    }
    val worker2 = Worker(bStrategy)
    worker1.work()
    worker2.work()
}

2023년 1월 21일 토요일

Enter Play Mode Settings설정으로 에디터에서 플레이를 빠르

Enter Play Mode Settings설정으로 에디터에서 플레이를 빠르

Enter Play Mode Settings 설정으로 에디터에서 플레이를 빠르게.!

작업중인 프로젝트를 에디터에서 Play 해보면 바로 플레이가 되지않고 뭔가 조금 시간이 지난후에(길게는 4-5초정도) 플레이가 실행된다.

이때.
Edit->Project Settings ->Editor 에 가면 Enter Play Mode Settins 가 있는데 이를 체크하고 에디터에서 Play버튼을 눌러보면 즉시 실행되는 것을 알수있다.

Reload Domain 과 Reload Scene 이 있는데 실행때만다 씬은 재생성하는게 좋으므로 Reload Scene만 체크하자.

Reload Domain 은 스크립팅 상태를 초기화 하는(?) 작업을 한다고 하는데 체크해 버리면 다시 시간이 걸려 버리므로 체크하지 않도록 한다.
단, 정적변수나 이벤트함수 등을 사용했다면 수동으로 초기화를 진행하도록 하여야 한다. ( 즉, 메모리에 올라간 정적 변수는 초기화 하지 않기 때문이다)

https://docs.unity3d.com/kr/2019.4/Manual/ConfigurableEnterPlayMode.html

에디터에서 실행시 특정씬부터시

에디터에서 실행시 특정씬부터시

에디터에서 플레이 시에 특정 씬 부터 시작되게 하는 팁

일단 구글링해서

https://stackoverflow.com/questions/35586103/unity3d-load-a-specific-scene-on-play-mode

에서 복불 해서 해보니 잘 됩니다.

작업중인게 설정읽기 씬(이런저런 자료 로딩) -> 시작화면씬 -> 배틀씬

이렇게 되어있는데 배틀씬에서 무심코 플레이 버튼눌렀을 때 스테이지 정보 등등 없다고 멈추면 다시 처음씬 로드해서 실행을 했었네요.

2023년 1월 16일 월요일

Android Tv App - Practice

Android Tv App - Practice

Android TV 앱 개발 E-BOOK (Practice)

Android TV Basic 에서는 TV앱 개발에 대한 간단한 설명을 했습니다.
이제 실제 우리앱을 만들어 봅시다.
( 아직 이 자료는 수정중에 있습니다.)

1. 프로젝트 생성

Android Studio 에서 New Project -> Android TV -> Blank Activity 를 선택해서 샘플 앱을 만듭니다.

프로젝트 생성후 build.gradle ( Module: xxx) 파일을 열어보면 leanback 라이브러리가 추가된것을 볼수 있습니다.

implementation 'androidx.leanback:leanback:1.0.0'

2. AndroidMenifest.xml 구성

Android TV도 일반앱과 동일한 아키텍쳐를 사용하기 때문에 보통의 앱과 비슷하게 manifest, application, activity, users-feature로 구성됩니다.
단, 아래의 속성이 추가되었기 때문에 정의를 해줄 필요가 있습니다.

  • android.software.leanback 기능사용
    • true 로 할경우 Android TV OS를 탑재한 기기에서는 leanback에서 의도한 대로 ui/ux가 동작하도록 합니다.
  • android.hardware.touchscreen 기능 사용
    • 터치스크린 대응이 아니므로 false 를합니다.
  • android:banner 속성추가
  • 처음실행되는 메인 Activity 의 Intent-filter 에 category속성으로 android.intent.category.LEANBACK_LAUNCHER 추가

위의 항목들을 추가한 상태는 아래와 같습니다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

  <application
      android:allowBackup="true"
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:supportsRtl="true"
      android:theme="@style/Theme.MyApplicationTV4"
      android:banner="@drawable/app_icon_your_company"
      >

  </application>
    <uses-feature
        android:name="android.hardware.touchscreen"
        android:required="false" />

    <uses-feature
        android:name="android.software.leanback"
        android:required="true" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

2.1 android:banner에 사용되는 배너 크기는 320x280

TV 화면에서 보일 앱의 배너사이즈는 320x280px 로 xhdpi 해상도에 맞게 제작해서 res/drawable 폴더에 저장하면 됩니다.
drawable 가 처음에는 없는 상태이므로,
res폴더에서 오른쪽버튼 -> new -> Android Resource Directory 를 선택해해서 대화상자가 나타나면 Resource Type에 drawable을 선택하고 OK 를 눌러 생성하면됩니다.
이미지 제작 프로그램에서 배너사이즈(320x280) 으로 제작된 이미지를 drawable폴더에 png 포맷으로 저장합니다.

2.2 android.permission.INTERNET 도 추가합니다.

TV앱은 주로 영상을 보여주거나 서버의 음성파일을 들려주는 앱이 많고, 우리가 만들앱도 외부의 영상을 보여주는 것이기 때문에 인터넷연결 퍼미션을 추가합니다.
xml의 manifest의 닫기태그 윗줄에 아래의 속성을 추가합니다.

    ...
    <uses-permission android:name="android.permission.INTERNET" />
</manifest>

3. SplashActivity 구성

Android TV앱을 구동시키면 처음 실행되는 Activity를 추가해 봅시다.
앞서 설명했듯이 Android TV OS는 앱이 실행될때 Leanback launcher가 설정되어있는지 확인하여 Leanback 에 적합하게 된 앱인지 확인하고 Leanback에 맞게 UX/UI가 움직이도록 반응게됩니다.

먼저 Activity를 추가합니다.
app->java->com.xxx.ooo 에서 마우스 오른쪽 버튼을 눌러서 New->Activity->Empty Activity 를 선택하여 SplashActivity라는 이름으로 추가합니다. 이때 Generate a Layout File을 선택하여 layout xml 도 같이 추가될수 있게 합니다.

3.1 android.intent.category.LEANBACK_LAUNCHER 를 추가

AndroidMenifest.xml 파일을 보면 방금 추가한 MainActiviy에 대한 activity정의가 추가되었습니다.
TV앱용으로 Leanback 사용자경험을 제공하려면 앱이 Leanback을 사용하여 제작되었다고 알려줄 수 있어야 하는데 category에 android.intent.category.LEANBACK_LAUNCHER 를 추가하면 됩니다.
아래와 같이 activity 태그내에 intent-filter를 추가합니다.

        <activity
            android:name=".SplashActivity"
            android:exported="false">
            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
            </intent-filter>
        </activity>

3.2 MainActivty가 FragmentActivity를 계승하도록 수정

새로생성한 MainActivity가 AppCompatActivity를 계승하지만, TV앱은 style/Theme.Leanback이라는 theme 를 사용하므로 FragmentActivity를 계승하도록 수정합니다.

class SplashActivity: FragmentActivity()  {

3.4 activity_splash.xml 수정

아직까지는 우리앱을 실행하면 그냥 서커먼 화면만 나오게 됩니다.
배경색하고 아이콘을 지정하여 봅시다.
activity_splash.xml 을 열어서 최상위 태그에 android:background속성을 추가합니다.

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" ...
     
   android:background="@color/splash_backcolor"
  
</androidx.constraintlayout.widget.ConstraintLayout>

color/splash_backcolor는 res/values/colors.xml 에 아래와 같이 정의되어 있습니다. 만일 colors.xml파일이 없다면
res/values폴더에서 오른쪽버튼 new Values xml 을 선택하고 이름은 colors.xml로 지정하여 생성합니다. 파일이 생성되었다면 resource태그사이에 아래와 같이 color태그를 추가합니다.

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <color name="splash_backcolor">#FFFF9747</color>
</resources>

그리고 화면 가운데 적당한 스플래시 이미지나 로고 파일을 배치합니다.
저는 그냥 배너이미지를 스플래시화면의 중앙에 로고처럼보이게 해보았습니다.

<?xml version="1.0" encoding="utf-8"?>  
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"  
  xmlns:app="http://schemas.android.com/apk/res-auto"  
  xmlns:tools="http://schemas.android.com/tools"  
  android:layout_width="match_parent"  
  android:layout_height="match_parent"  
  tools:context=".SplashActivity"  
  android:background="@color/splash_backcolor"  
  >  
  
  <ImageView  
  android:id="@+id/imageView"  
  android:layout_width="wrap_content"  
  android:layout_height="wrap_content"  
  app:layout_constraintBottom_toBottomOf="parent"  
  app:layout_constraintEnd_toEndOf="parent"  
  app:layout_constraintStart_toStartOf="parent"  
  app:layout_constraintTop_toTopOf="parent"   
  android:src="@drawable/app_icon_your_company"
  />  
</androidx.constraintlayout.widget.ConstraintLayout>

4. ViewBinding 하기

androidx 에서는 viewBinding이라는 것을 제공하는데, xml 로 구성된 화면view파일의 각 요소들을 개발자가 findById 라는 번거로운 함수사용하지 않고도 쉽게 view요소들에 접근할 수 있도록 해줍니다.
앞서 TV-Basic샘플에서도 ActivityDetailsBinding, FragmentSettingBinding 처럼 xxxBinding 이라는 클래스를 변수에 할당해서 binding.root, binding.txtView처럼 layout xml에서 구성한 요소들을 쉽게 접근 할 수있는 것을 보았을 겁니다. ( 못 봤다면 다시 한번 보세요)

4.1 build.gradle에 viewBinding 을 true로 하기

viewBinding은 android JetPack이라는 라이브러리를 통해서 제공하므로 개발자가 viewBinding을 사용하겠다고 build.gradle파일에 정의해 주어야합니다.
build.gradle (module:app) 에 아래와 같이 추가합니다.

    
android {
  ...
  buildFeatures {  
    viewBinding  true  
  }
  ...
}

이렇게 추가하고 gradle의 sync now를 실행하고 나면, ViewBinding을 할수있습니다.

4.2 SplashActivity에서 ViewBinding이용하기

SplashActivity를 열고 아래와 같이 binding된 레이아웃을 변수에 넣어서 쉽게 이용할수 있습니다.

activiy_main.xml -> ActivityMainBiding 와 같이 자동으로 클래스파이이 생성되기 때문에 해당 레이아웃을 사용하고 싶을때는 아래와 같이 자동생성된 XXXBinding 클래스를 불러내기만 하면됩니다.
기존에 컨텐츠 레이아웃을 지정하던 SetContentView도 바인딩된 뷰의 root 노드를 지정하면 된다.

class SplashActivity: FragmentActivity()  {    
    // ActivitySplashBinding은 ViewBinding으로 자동으로 만들어진 파일입니다.
    private lateinit var myBinding: ActivitySplashBinding    
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        // 변수 myBinding의 최상위뷰(root)를 현재 레이아웃의 컨텐츠뷰표시 뷰로 사용합니다.
        myBinding = ActivitySplashBinding.inflate(layoutInflater)  
        setContentView(binding.root)  
    }  
}

5. view요소를 animation하기

Splash화면에 로고가 표시될떄 좀더 자연스럽게 보이도록 애니메이션효과를 추가해봅시다.
View 요소의 속성을 쉽게 애니메이션 시킬수 있는 ObjectAnimator기능을 이용하여 간단하게 FadeIn 효과를 추가할수 있스비다.

5.1 ObjectAnimator이용하기

ObjectAnimator를 이용하면 View 요소의 각종속성(x,y위치, 투명도, 크기등)을 지정된 시간동안 자연스럽게 애니메이션 시킬수 있습니다.

SplashActivity 의 Oncreate 함수안에 아래와 같이 입력합니다.

    override fun onCreate(savedInstanceState: Bundle?) {
        ....
        // 3초(=3000ms)동안 애니메이션합니다.
        val animationDuring = 3000L
        // ObjectAnimation을 지정합니다. 마지막 두개의 인수는 애니메이션시킬 속성의 시작값,종료값 입니다.
        val alphaAnimation = ObjectAnimator.ofFloat(myBinding.imageView, View.ALPHA, 0f, 1f)
        // 애니메이션 시간을 지정하고 애니메이션을 시작합니다.
        alphaAnimation.setDuration(animationDuring) .start()
    }

이 밖에 ValueAnimator와 여러 속성을 동시에 애니메이션시킬수있는 AnimatorSet가 있습니다. 차차 하나씩 소개할 예정입니다.

5.2 애니메이션의 시작과 끝에 Listerner이벤트를 넣어서 다른 행동 하도록하기

애니메이션을 시작할때 음악을 재생하고 싶거나, 끝날때 다른 Activity로 이동시키고 싶을때는 AnimationListener에 onAnimationXXX 함수를 재정의하면됩니다.

우리는 로고애니메이션이 시작할때 음악을 재생하고, 애니메이션이 다 끝나면 현재 액티비티를 종료하고 MainActivity로 이동시키고자 합니다.

위의 Oncreate함수안에 아래와 같이 추가합니다.
단, AddListener를 animation의play보다 나중에 추가하면 onAnimationStart함수가 불러지지 않을수 있으니
alphaAnimation.setDuration(animationDuring).start() 처리 라인 이전에 추가합니다.

    override fun onCreate(savedInstanceState: Bundle?) {
        ...               
        // addListener를 통해 시작,종료시 실행할 처리를 추가합니다.
        alphaAnimation.addListener(object : AnimatorListenerAdapter() {
            val mediaPlayer = MediaPlayer()
            val audioUrl = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-17.mp3"
            // 애니메이션 시작과 동시에 처리할 내용입니다.  외부의 mp3 를 재생합니다.
            override fun onAnimationStart(animation: Animator?) {
                super.onAnimationStart(animation)            
                
                mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC)
                try {
                    mediaPlayer.setDataSource(audioUrl)
                    mediaPlayer.prepare()
                    mediaPlayer.start()
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            }

            // 애니메이션 끝남과 동시에 처리할 내용입니다. Splash화면을 종료하고 앱의 메인 화면을 보여줍니다.
            override fun onAnimationEnd(animation: Animator?) {
                super.onAnimationEnd(animation)
                mediaPlayer.stop()
                mediaPlayer.release()
                finish()
                // TODO : startActivity(Intent(baseContext, OnBoardingActivity::class.java))
            }
        })
        alphaAnimation.setDuration(animationDuring).start()
    }

6. OnBoardingActivity 구현

Onboarding 란 시작전에 봐야될 것들, 안내할 것들을 보여주는 화면입니다.
비행기나 배를 탈때 이용안내문 같은 거라고 생각하면됩니다.
Leanback 라이브러리에서는 OnboardingSupportFragment을 제공하여주어 안내할 내용이 여러개일때도 단계별로 표시할수 있도록 해줍니다.

6.1 OnboardingActivity를 추가

New -> Activity -> BlankActivity. 를 추가하여 이름을 OnboardingActivity라고 추가합니다.

AndroidManifest.xml 에 OnboardingActivity가 추가되었지만 leanbackOnboarding화면으로 사용하기 위해서는 Them를 아래와 같이 추가합니다.

<activity  
  android:name=".OnboardingActivity"  
  android:theme="@style/Theme.Leanback.Onboarding"  
  android:exported="true"  
  android:screenOrientation="landscape">  
</activity>

6.3 viewBinding 하기

OnboardingActivitiy파일을 열어서 viewBinding을 해줍니다.

class OnboardingActivity : FragmentActivity() {
    private lateinit var binding: ActivityOnboardingBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
    }
}

6.2 activity_onboarding.xml을 수정

activity_onboarding.xml 을 아래와 같이 수정합니다.
aaa.bbb.xxx.OnboardingFragment 라는 fragment를 아직 추가안했기 때문에 현재는 붉은 경고 표시글씨로 보입니다.

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"  
  xmlns:tools="http://schemas.android.com/tools"  
  android:id="@+id/onboarding_fragment"  
  android:layout_width="match_parent"  
  android:layout_height="match_parent"  
  tools:context=".OnboardingActivity"  
  tools:deviceIds="tv"  
  tools:ignore="MergeRootFrame"  
>  
    <fragment  
  android:layout_width="match_parent"  
  android:layout_height="match_parent"  
  android:name="aaa.bbb.xxx.OnboardingFragment"  
  android:id="@+id/Onboardingfragment"  
  >  
    </fragment>  
</FrameLayout>

6.3 OnboardingFragment추가

new->Fragment->Fragment(Blank) 를 선택후에 Fragment Name을 OnboardingFragment로 입력하여 fragment를 생성합니다.

OnboardingFragment는 leanback에서 제공하는 를 계승해서 만듭니다.
온보딩의 화면에서 사용자가 내용을 다 본후에 메인화면으로 넘어갈수 있도록 onFinishFragment함수를 재정해줄 필요가 있습니다.

class OnboardingFragment : OnboardingSupportFragment() {  
  
    override fun onCreateView(  
        inflater: LayoutInflater,  
        container: ViewGroup?,  
        savedInstanceState: Bundle?  
    ): View? {  
        val view = super.onCreateView(inflater, container, savedInstanceState)  
        view?.setBackgroundColor(Color.Black)  
  
        return view  
    }  
  
    // 사용자가 모든 페이지를 보고 마지막 페이지에서 확인 버튼등을 눌렀을때 처리할 내용입니다. 보통 앱의 메인페이지로 이동하도록합니다.
    override fun onFinishFragment() {  
        super.onFinishFragment()  
        // Our onboarding is done  
        requireActivity().finish()  
       //TODO : startActivity(Intent(context , MainActivity::class.java))  
    }  
  
    //총 몇 페이지(화면)으로 구성할지 결정합니다.
    override fun getPageCount(): Int {  
        return 2  
  }  
  
    override fun getPageTitle(pageIndex: Int): CharSequence = when (pageIndex) {  
    0 -> "처음 페이지"  
    1 -> "마지막 페이지"  
    else -> null.toString()  
}
  
    override fun getPageDescription(pageIndex: Int): CharSequence {  
        return null.toString()  
    }  
  
    override fun onCreateBackgroundView(inflater: LayoutInflater?, container: ViewGroup?): View? {  
        return null  
  }  
  
    override fun onCreateContentView(inflater: LayoutInflater?, container: ViewGroup?): View? {  
        return null  
  }  
  
    override fun onCreateForegroundView(inflater: LayoutInflater?, container: ViewGroup?): View? {  
        return null  
  }  
}

재정의 함수에 null 이 대입되어있지만, pageIndex와 contentview의 재정의를 하면 각 페이지의 내용을 다양하게 구성할수 있습니다.

이제 6.2 과정에서 android:name=“aaa.bbb.xxx.OnboardingFragment” 부분의 글씨가 녹색으로 바꿔었을겁니다.

그리고 지금은 leanback의 OnboardingFragment를 사용하기 때문에 자동으로 생성된 fragment의 레이아웃 파일(res/layout/fragment_onboarding.xml)을 지웁니다.

6.4 2개의 페이지로 구성된 Onboarding

앱을 실행해 보면 스플래쉬화면->온보딩화면 으로 이동되고 2개의 페이지중 마지막 페이지에 도달하면 Get Start버튼이 보입니다.

현재는 해당 버튼을 누르면 과정 6.3의 onFinishFragment처리에 의해 앱이 종료됩니다.
앞으로 MainActivity를 만들어서 메인으로 이동하게 할 수 있습니다.

7. MainActivity 구현

우리가 만들 TV앱의 실제 척화면인 MainActivity를 구현해 봅니다.
MainActivity의 구성은 좌측에 카테고리 메뉴, 우측에 비디오 리스트가 위치하도록 할 예정입니다.
우측의 영역은 비디오리스트, 앱안내, 회원가입 등 여러개의 Fragment를 이용하여 싱글 액티비티+멀티플래그먼트로 구성합니다.

7.1 Android TV Blank Activity

MainActivity를 생성할때는 TV 앱용 템플릿으로 구성된 Android TV Blank Activity를 이용하면 리스트화면에서 상세보기화면까지 구성된 세트의 액티비티들이 생성되어 집니다.

패키지에서 new->Activity->Android TV Blank Activity를 선택하면,

  • MainActivity(layout 과 Fragment 포함)
  • DetailActivity(layout 과 Fragment 포함)
    세트의 파일들이 자동을 생성되어 집니다.
    또한 화면구성에 사용되는 XXXPresenter 파일들과 샘플데이터인 MovieXXX파일들, 동영상 플레이용 Activity가 그리고
    drawable폴더에 샘플용 이미지도 추가되어집니다.

프로젝트의 AndroidMenifest.xml파일도 멋대로 수정되어버렸으니, 다시 우리의 SplashActivity를 처음시작 앱티비티로 지정합니다.
아래와 같이 MainActivity에 지정된 항목들을 삭제하고, SplashActivity에 banner와 logo를 추가합니다.

<activity  
  android:name=".SplashActivity"  
  android:banner="@drawable/app_icon_your_company"  
  android:enabled="true"  
  android:exported="true"  
  android:logo="@drawable/app_icon_your_company">  
    <intent-filter>  
        <action android:name="android.intent.action.MAIN" />  
  
        <category android:name="android.intent.category.LEANBACK_LAUNCHER" />  
    </intent-filter>  
</activity>  
<activity  
  android:name=".MainActivity"  
  android:exported="true"  
  android:screenOrientation="landscape">  
</activity>

6.3과정에서 우리는 Onboarding화면에서 [Get Started]버튼을 클릭할때 MainActivity 로 이동하도록 했었습니다. 이제는 MainActivity가 생성되었으므로 주석처리를 삭제합니다.

    override fun onFinishFragment() {  
        super.onFinishFragment()  
        // Our onboarding is done  
        requireActivity().finish()  
        startActivity(Intent(context , MainActivity::class.java))  
    }  

7.2 자동생성된 Activiy,들을 ViewBinding하기

자동생성된 Activity,들은 ViewBinding처리가 되어 있지않습니다. 번거롭지만 나중을 위해서 ViewBinding으로 바꾸어 줍니다.

  • MainAcitivy
class MainActivity : FragmentActivity() {  
  
    private lateinit var binding: ActivityMainBinding  
  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        binding = ActivityMainBinding.inflate(layoutInflater)  
        setContentView(binding.root)  
        if (savedInstanceState == null) {  
            getSupportFragmentManager().beginTransaction()  
                .replace(binding.mainBrowseFragment.id, MainFragment())  
                .commitNow()  
        }  
    }  
}
  • DetailsActivity
  • BrowserErrorActivity
    도 R.layout 또는 R.id 로 적혀있는부분을 binding 을 이용한 방식으로 수정합니다.

단, PlaybackActivity는 FragmentActivity의 기본레이아웃의android.R.id.content에 전체화면으로 표시되는 뷰에 PlaybackVideoFragment를 표시하도록 하위해서 별도의 layout 파일이 없습니다.

앱을 실행해보면, 스플래쉬화면->온보딩->메인화면으로 이동하는것을 볼수 있습니다.

7.3 BrowseSupportFragment를 계승한 MainFragment

Android Tv App -Basic 에서 MainFragment의 구성을 살펴 본 적이 있습니다.
다시 간단히 말하자면, 헤더부분인 주메뉴 카테고리목록, 각 목록에 연결되어지는 영상아이템, 사용자정의 Grid 아이템뷰 로 구성되어 있습니다.

7.3.1 기본 형태의 BrowseSupportFragment 상태보기

MainFragment에는 이미 앱이 실행될수 있을만큼 코딩이 되어있습니다.
Leanback에서 제공해 주는 기본형태의 BrowseSupportFragment는 어떤 모습인지 한번 봅시다.

모든 코딩 블록을 삭제하고 아래와 같이 기본 형태만 남겨놓습니다.

class MainFragment : BrowseSupportFragment() 
// 이하 모드 삭제 되어 없음

이렇게 놓고 실행을 해도 헤더영역과 리스트영역이 구분된 레이아웃이 leanback에 의해 자동으로 구성되어 진 것입니다.

7.3.2 BrowseSupportFragment 꾸미기

BrowseSupportFragment에 미리 설정되어있는 디자인 요소들의 속성을 지정해서 화면을 꾸며봅시다.
화면의 UI요소를 변경하는 함수 setupUIElements 를 만들어서 아래와 같이 입력합니다.


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {  
    super.onViewCreated(view, savedInstanceState)  
  
    setupUIElements()  
}

private fun setupUIElements() {  
    // 화면의 타이틀 지정
    title = getString(R.string.browse_title)  
    
    // 카테고리 목록이 보일 헤더는 HEADERS_ENABLED ,HEADERS_HIDDEN, HEADERS_DISABLED 을 선택하여 지정할 수 있습니다. 
    headersState = BrowseSupportFragment.HEADERS_ENABLED  
    // true 일때는 백버튼을 누를때 헤더로 이동가능하도록 합니다.
    isHeadersTransitionOnBackEnabled = true  
  
  // 카테고리 목록이 보일 헤더 배경색지정
  brandColor = ContextCompat.getColor(requireActivity(), R.color.fastlane_background)  
}

R.string.browser_title은 values/strings.xml , R.color.fastlane_background 는 value/colors.xml 에 MainActivity 생성시 자동으로 추가된 속성이므로 해당 값을 마음대로 바꾸어도 됩니다.

7.3.3 헤더아이템과 리스트아이템 구성하기

이제 헤더(카테고리)아이템과 해당 헤더에 맞는 아이템을 추가하여 봅시다.
먼저 MainFragment가 상속받은 BrowseSupportFragment에 다시 한번 알아봅니다.
BrowseSupportFragment는 말그대로 브라우징이 가능한 것을 도와주는 Fragment를 말합니다.
브라우징 가능한, 즉 탐색이 가능하도록 큰카테고리와 하위의 아이템(하위컨텐츠)들로 구성된 구조로 되어 있습니다.

따라서 BrowseSupportFragment를 이용한다면 미리정의되어있는 Header와 Row List 를 지정해 주면 자동으로 그들의 관계를 맺어주어 브라우징이 가능한 상태로 만들어 줍니다.

좌측 카테고리(주제)가 나오는 부분은 HeaderItem 형식으로 추가합니다.

    
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {  
    super.onViewCreated(view, savedInstanceState)  
    ...
    initHeaderAndRows()  
}
  
private fun initHeaderAndRows() {  
    val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())  
    adapter = rowsAdapter  
}

BrowseSupportFragment에는 한개의 대표 adapter가 존재합니다. 이 아답터에 헤더와 리스트아이템을 구현한 ArrayObjectAdapter를 지정하면 화면구성이 이루어 집니다.
Presenter는 화면에 아이템들이 출력될때 어떠한 형태로 출력될지를 결정하게 해줍니다.
ListRowPresenter는 목록형태로 아이템들을 출력하는 프레젠터를 말합니다.

아직 헤더아이템을 추가안했기 때문에 아무것도 화면에 나오지 않습니다.
헤더아이템을 추가해 봅니다.

private fun initHeaderAndRows() {  
    val myRowsAdapter = ArrayObjectAdapter(ListRowPresenter())  
  
    // 헤더 추가  
    val header1 = HeaderItem(1, "첫번째 메뉴")  
    val header2 = HeaderItem(2, "첫번째 메뉴")  
      
    myRowsAdapter.add(ListRow(header1, ArrayObjectAdapter(VerticalGridPresenter())))  
    myRowsAdapter.add(ListRow(header2, ArrayObjectAdapter(VerticalGridPresenter())))  
  
    adapter = myRowsAdapter  
}

실행해 보면 두개의 메뉴가 추가되어 있고, 패드의 위,아래 버튼을 누르면 글씨가 커지면서 메뉴와 화면이 움직이는 것을 볼수 있습니다.
우축의 아이템 리스트를 추가 안해서 아무것도 보이지 않으니, 아이템 리스트를 추가해 봅시다.

7.3.4 사용자정의 Presenter파일 만들기

아이템 리스트도 마찬가지로 ArrayObjectAdapter와 Presenter를 이용하여 데이터를 출력하는 구조입니다.
위의 예제에서는 Leanback에서 제공하는 VerticalGridPresenter를 이용하고 있습니다만 지금은 간단한 문자열만 출력할 것이기 때문에 직접 프레젠터를 만들어서 사용합시다.

먼저 아이템 디자인 파일인 item_box.xml을 추가합니다.

/res/layout/item_box.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:background="#FFA24242"  
    android:focusable="true"  
    android:focusableInTouchMode="true"
    >

  <TextView
      android:id="@+id/textView"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="TextView"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent"
      />
</androidx.constraintlayout.widget.ConstraintLayout>

이제 MyItemPresenter를 추가합니다.

    private fun initHeaderAndRows() {
       ...
    }

    class MyItemPresenter : Presenter() {
        override fun onCreateViewHolder(parent: ViewGroup?): MyViewHolder {
            val itemView: View =
                LayoutInflater.from(parent?.context)
                    .inflate(
                        R.layout.item_box,
                        parent,
                        false
                    )
            return MyViewHolder(itemView)
        }

        override fun onBindViewHolder(viewHolder: ViewHolder?, item: Any?) {
            (viewHolder!! as MyViewHolder).setText(item as String?)
        }

        override fun onUnbindViewHolder(viewHolder: ViewHolder?) {
            TODO("Not yet implemented")
        }

        inner class MyViewHolder(view:View) : ViewHolder(view) {
            private  var textView: TextView

            init {
                textView = view.findViewById(R.id.textView)
            }

            fun setText(inputText:String?) {
                textView.text = inputText
            }
        }
    }

MyItemPresenter는 MyViewHolder 를 이용하여 아이템이 화면에 보여지는순간, onBindViewHolder의 순간에 지정된 데이터를 TextViw에 출력합니다. item_box.xml파일을 좀더 이쁘게 꾸민다면 다양한 디자인으로 보여주게 할수도 있습니다.

자이제 아까 VerticalGridPresenter를 적었던 곳에 우리가 만든 MyItemPresenter로 바꾸고 아이템들을 추가해 봅시다.

private fun initHeaderAndRows() {  
    val myRowsAdapter = ArrayObjectAdapter(ListRowPresenter())  
  
    // 헤더 추가  
    val header1 = HeaderItem(1, "첫번째 메뉴")  
    val header2 = HeaderItem(2, "두번째 메뉴")  
  
    // 리스트 목록 추가    
    val listRowAdapter1 = ArrayObjectAdapter(MyItemPresenter())  
    listRowAdapter1.add("첫째메뉴아이템1")  
    listRowAdapter1.add("첫째메뉴아이템2")  
    listRowAdapter1.add("첫째메뉴아이템3")  
  
    val listRowAdapter2 = ArrayObjectAdapter(MyItemPresenter())  
    listRowAdapter2.add("둘째메뉴아이템1")  
    listRowAdapter2.add("둘째메뉴아이템2")  
    listRowAdapter2.add("둘째메뉴아이템3")  
  
  
    myRowsAdapter.add(ListRow(header1, listRowAdapter1))  
    myRowsAdapter.add(ListRow(header2, listRowAdapter2))  
  
    adapter = myRowsAdapter  
}

실행해 보면 첫째, 둘째 아이템리스트들이 보여지고, 왼쪽의 헤더 카테고리를 이동함에 따라서 아이템목록도 자동으로 연동되어 집니다.
포커스를 오른쪽으로 이동시키면 헤더(카테고리) 구분된 리스트목록이 멋지게 나옵니다.
이러한 처리를 직접 하려면 매우 귀찮은 작업이었을텐데 BrowseSupportFragment의 기본아답터에서 자동으로 처리하여 주었습니다.

7.3.5 이미지와 타이틀이 있는 카드형 아이템 프레젠터 만들기

앞의 예제에서는 첫번쨰와 두번째 메뉴가 동일한 아이템모양으로 지정했습니다. 이번에는 두번째 메뉴는 이미지와 설명이 있는 카드형 아이템 프레젠터를 지정해봅시다.

Leanback 에서 기본으로 제공하는 ImageCardView를 이용하면 리스트화면일때는 이미지만, 확대 리스트화면에서는 이미지와 설명까지 포함된 디자인을 멋지게 보여주는 처리를 쉽게 표현할수 있습니다.
item_cardbox.xml 을 만들어 이미지와 텍스트를 배치합니다.

/res/layout/item_cardbox.xml
<?xml version="1.0" encoding="utf-8"?>  
<androidx.leanback.widget.ImageCardView  
  xmlns:android="http://schemas.android.com/apk/res/android"  
  xmlns:app="http://schemas.android.com/apk/res-auto"  
  android:id="@+id/cardView"  
  android:layout_width="wrap_content"  
  android:layout_height="wrap_content"  
  android:focusable="true"  
  android:focusableInTouchMode="true"  
  >  
</androidx.leanback.widget.ImageCardView>

카드형태를 리스트아이템으로 보이기 위해서는 프레젠터를 추가해야 합니다.

class MyCardItemPresenter : Presenter() {  
    private lateinit var defaultPosterImage: Drawable  
  
    override fun onCreateViewHolder(parent: ViewGroup?): MyViewHolder {  
        val itemView: View =  
            LayoutInflater.from(parent?.context)  
                .inflate(  
                    R.layout.item_cardbox,  
                    parent,  
                    false  
  )  
        defaultPosterImage = parent?.context?.getDrawable(R.drawable.movie)!!  
        return MyViewHolder(itemView)  
    }  
  
    override fun onBindViewHolder(viewHolder: ViewHolder?, item: Any?) {  
        (viewHolder!! as MyViewHolder).apply {  
  setTitleText(item as String?)  
            setContentText(item)  
            setMainImage()  
        }  
  }  
  
    override fun onUnbindViewHolder(viewHolder: ViewHolder?) {  
        TODO("Not yet implemented")  
    }  
  
    fun getDetaultPosterImage() = defaultPosterImage  
  
  inner class MyViewHolder(view: View) : ViewHolder(view) {  
        private var imageCardView: ImageCardView  
  
        init {  
            imageCardView =(view as ImageCardView)  
        }  
  
        fun setTitleText(inputText:String?) {  
            imageCardView.titleText = inputText  
        }  
        fun setContentText(inputText:String?) {  
            imageCardView.contentText = inputText  
        }  
        fun setMainImage(imgUrl: String?) {  
            imageCardView.mainImage = getDetaultPosterImage()  
        }  
    }  
}

MainFragment에서 MycardItemPresenter를 지정합니다.

val listRowAdapter2 = ArrayObjectAdapter(MyCardItemPresenter())

실행해보면 첫번째 아이템리스트는 MyItemPresenter디자인으로 보이고, 두번쨰아이템은 MyCardItemPresenter로 보입니다.
ImageCardView는 작은리스트일때는 이미지만보이고, 큰리스트화면에서는 이미지와 설명이 보이도록 되어있습니다.

7.3.6 데이터 클래스를 이용하기

imageCardView는 타이틀,내용,이미지가 하나의 아이템데이터로 구성됩니다. 이런 데이터를 구성하기 제일 편한 방법은 data class 로 데이터형식을 만드는 것입니다.

new->data class를 생성합니다.

data class CardItemData(  
    val title:String,  
    val content: String,  
    val imageUrl: String,  
)

CardItemData형식으로 두번째메뉴아이템들을 추가합니다.

val listRowAdapter2 = ArrayObjectAdapter(MyCardItemPresenter())  
listRowAdapter2.add(  
    CardItemData(  
        title = "타이틀1",  
        content = "컨텐츠1",  
        imageUrl = "https://m.media-amazon.com/images/M/MV5BMjYzMGU0MzUtMTUxYy00MzE4LWFiMjctOTgwODg1ZjkwNTc0XkEyXkFqcGdeQXVyMTAyMjQ3NzQ1._V1_UX140_CR0,0,140,209_AL_.jpg"  
  )  
)  
listRowAdapter2.add(  
    CardItemData(  
        title = "타이틀2",  
        content = "컨텐츠2",  
        imageUrl = "https://m.media-amazon.com/images/M/MV5BMWUwOThjYTAtZWYyYy00YjllLTkxYjEtNTJmNTI5N2M1NjkxXkEyXkFqcGdeQXVyOTU0NjY1MDM@._V1_UX140_CR0,0,140,209_AL_.jpg"  
  )  
)  
listRowAdapter2.add(  
    CardItemData(  
        title = "타이틀3",  
        content = "컨텐츠3",  
        imageUrl = "https://m.media-amazon.com/images/M/MV5BMDU2ZmM2OTYtNzIxYy00NjM5LTliNGQtN2JmOWQzYTBmZWUzXkEyXkFqcGdeQXVyMTkxNjUyNQ@@._V1_UY209_CR0,0,140,209_AL_.jpg"  
  )  
)

이제 프레젠터에서도 CardItemData형식의 데이터를 각 뷰에 적용할수 있도록 해야합니다.

MyCardItemPresenter.kt
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {  
    (viewHolder as MyViewHolder).apply {  
		val data = (item as CardItemData)  
		setTitleText(data.title)  
		setContentText(data.content)  
		setMainImage(data.imageUrl) 
 }
}

실행해 보면 타이틀1,2,3 보이게 됩니다.

7.3.7 외부의 포스터이미지를 불러오기

지금까지는 R.drawable.movie의 기본이미지를 출력했습니다. 7.3.6에서 지정한 외부의 이미지를 불러와서 imageCardView의 이미지영역에 지정해 봅시다.
외부의 이미지를 불러오는 라이브러리가 여러가지 있는데, 지금은 MainAcitivity를 추가할때 자동으로 추가된 Glide 라이브러리를 사용합니다.

class MyCardItemPresenter : Presenter() {  
    private lateinit var defaultPosterImage: Drawable  
  
    override fun onCreateViewHolder(parent: ViewGroup?): MyViewHolder {  
        val itemView: View =  
            LayoutInflater.from(parent?.context)  
                .inflate(  
                    R.layout.item_cardbox,  
                    parent,  
                    false  
  )  
        defaultPosterImage = parent?.context?.getDrawable(R.drawable.movie)!!  
        return MyViewHolder(itemView)  
    }  
  
    override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {  
        (viewHolder as MyViewHolder).apply {  
  val data = (item as CardItemData)  
            setTitleText(data.title)  
            setContentText(data.content)  
            Glide.with(viewHolder.view.context)  
                .load(data.imageUrl)  
                .centerCrop()  
                .error(defaultPosterImage)  
                .into(getMainImageView())  
        }  
  }  
  
    override fun onUnbindViewHolder(viewHolder: ViewHolder?) {  
        TODO("Not yet implemented")  
    }  
  
    inner class MyViewHolder(view: View) : ViewHolder(view) {  
        private var imageCardView: ImageCardView  
  
        init {  
            imageCardView = (view as ImageCardView)  
            imageCardView.setMainImageDimensions(280, 418)  
        }  
  
        fun setTitleText(inputText: String?) {  
            imageCardView.titleText = inputText  
        }  
  
        fun setContentText(inputText: String?) {  
            imageCardView.contentText = inputText  
        }  
  
        fun getMainImageView() = imageCardView.mainImageView  
  }  
}

Glide라이브러리는 온라인에서 데이터를 가져와서 이미지뷰에 적용하는 기능을 하기때문에 MyViewHolder에서 getMainImageVIew에서 이미지뷰를 Glide에게 넘겨주도롭 했습니다.
이제 실행해 보면 제법 그럴듯한 화면이 구성되어 있습니다.
ImageCardView는 cardtype, infovisibility, extravisibility등 다양한 옵션이 있으니 추가옵션들도 적용해 보며 테스트 해보면 좋습니다.

7.3.8 아이템 클릭 처리

BrowserSupportFragment 에서는 아이템의 선택(onItemViewSelectedListener) 와 아이팀클릭(onItemViewClickedListener)가 정의되어 있기때문에 해당처리를 하는 리스너를 생성해서 등록해 주면 됩니다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {  
    ...  
    onItemViewClickedListener = ItemViewClickedListener()  
}

MyItemViewClickedListener를 추가합니다.

  
class MainFragment : BrowseSupportFragment() {
	...
	private inner class MyItemViewClickedListener : OnItemViewClickedListener {  
	    override fun onItemClicked(  
	        itemViewHolder: Presenter.ViewHolder,  
	        item: Any,  
	        rowViewHolder: RowPresenter.ViewHolder,  
	        row: Row  
	    ) {  
	        when(item) {  
	            is CardItemData -> {  
	                Toast.makeText(requireActivity(), item.title, Toast.LENGTH_SHORT).show()  
	            }  
	            is String -> {  
	                Log.d("TAG", "Item: " + item)  
	            }  
	        }  
	    }  
	}

item의 데이터 형식에 따라서 Toast창을 띄우거나 그냥 Logcat에 로그를 남기도록 처리하였습니다.
이제 해당 아이템을 클릭했을때 상세정보가 보일수 있도록 상세화면으로 이동하는 작업을 해 보겠습니다.

8. DetailActivity 구현

아이템을 클릭했을때 아이템의 상세정보 화면을 구현해봅시다.
MainActivity를 추가했을때 Leanback에서는 DetailsActivity도 세트로 같이 추가하였습니다.
DetailActivity는 단순히 VideoDetailFragment를 불러들이는 기능만 하기때문에 VideoDetailFragment를 수정해 봅시다. 먼저 DetailActivity에서 사용할 activty_details.xml이 있는지 확인하고 없으면 생성해줍니.

activity_details.xml
<?xml version="1.0" encoding="utf-8"?>  
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"  
  xmlns:tools="http://schemas.android.com/tools"  
  android:id="@+id/details_fragment"  
  android:layout_width="match_parent"  
  android:layout_height="match_parent"  
  tools:context=".DetailsActivity"  
  tools:deviceIds="tv"  
  />

8.1 VideoDetailFragment

VideoDetailFragment는 DetailsSupportFragment를 계승하였습니다.
자동으로 생성된 부분을 삭제하고 아래와 같이 기본 형태만 남겨놓습니다.

class VideoDetailsFragment : DetailsSupportFragment()
// 이하삭제

8.2 MainActivity에서 DetailActivity이동하기

7.3.8에서 아이템을 클릭할때 단순히 Toast만 보여지도록하였는데 이부분을 DetailActivity로 이동할 수있도록 수정합니다.

private inner class MyItemViewClickedListener : OnItemViewClickedListener {  
    override fun onItemClicked(  
        itemViewHolder: Presenter.ViewHolder,  
        item: Any,  
        rowViewHolder: RowPresenter.ViewHolder,  
        row: Row  
    ) {  
        when(item) {  
            is CardItemData -> {  
                val intent = Intent(activity!!, DetailsActivity::class.java)  
                startActivity(intent)  
            }  
            is String -> {  
                Log.d("TAG", "Item: " + item)  
            }  
        }  
    }  
}

실행을 하고 메인화면에서 영화포스터아이콘을 클릭하면 상세화면으로 이동하게됩니다. 상세화면은 아직 아무 작업도 안했으니 검은 화면만 나옵니다.

8.3 VideoDetailFragment 구성하기

DetailsSupportFragment화면은 배경, 대표이미지, 대표설명 등 기타 액션세트등을 구성할수 있습니다.

8.3.1 DetailsSupportFragmentBackgroundController

DetailsSupportFragmentBackgroundController 는 상세보기 화면의 배경에 이미지나 영상을 보여줄수 있도록 합니다.
배경이미지를 지정하기전에 DetailsSupportFragment에서 각요소의 출력을 제어하는 adapter와 표시방식을 지정하는 presenter를 지정해야 합니다.

class VideoDetailsFragment : DetailsSupportFragment() {  
    private lateinit var mDetailsBackground: DetailsSupportFragmentBackgroundController  
    private lateinit var mPresenterSelector: ClassPresenterSelector  
    private lateinit var mAdapter: ArrayObjectAdapter  
  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
  
        mPresenterSelector = ClassPresenterSelector()  
        mAdapter = ArrayObjectAdapter(mPresenterSelector)  
  
 mDetailsBackground = DetailsSupportFragmentBackgroundController(this)  
        Glide.with(requireActivity())  
            .asBitmap()  
            .centerCrop()  
            .error(R.drawable.default_background)  
            .load("https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search/bg.jpg")  
            .into(object: CustomTarget<Bitmap>() {  
                override fun onResourceReady(bitmap: Bitmap,  
                    transition: Transition<in Bitmap>?) {  
                    mDetailsBackground.enableParallax()  
                    mDetailsBackground.coverBitmap = bitmap  
                }  
  
                override fun onLoadCleared(placeholder: Drawable?) {  
                    TODO("Not yet implemented")  
                }  
            })  
	    adapter = mAdapter 
    }  
}

실행해 보면 배경 이미지가 표시된 상세페이지가 보일겁니다.

8.3.2 상세페이지에서 아이템로고와 액션, 상세설명 데이터정의하기

상세페이지에서 아이템로고,액션,상세설명을 보여줄수 잇도록 FullwidthDetailsOverviewRowPresenter가 leanback에서는 이미 준비되어있습니다. 우리는 DetailSupportFragment의 대표아답터에게 이 프레젠터를 지정하여 주면 됩니다.

setupDetailsOverviewRow함수를 만들어 onCreate에서 호출합니다.

    
override fun onCreate(savedInstanceState: Bundle?) {  
    super.onCreate(savedInstanceState)
    ...
    setupDetailsOverviewRow()      
    adapter = mAdapter 
}
  
private fun setupDetailsOverviewRow() {
}

먼저, Leanback의 DetailsOverviewRow라는 것을 이용하여 상세화면에서 사용할 데이터를 지정합니다.

private fun setupDetailsOverviewRow() {  
    val cardItemData = CardItemData(  
    title = "title",  
    content = "content",  
    imageUrl = "https://m.media-amazon.com/images/M/MV5BMjYzMGU0MzUtMTUxYy00MzE4LWFiMjctOTgwODg1ZjkwNTc0XkEyXkFqcGdeQXVyMTAyMjQ3NzQ1._V1_UX140_CR0,0,140,209_AL_.jpg"  
)  
  
val row = DetailsOverviewRow(cardItemData)

이미지가 출력되는 부분의 크기를 지정하고 외부의 이미지를 불러올수있도록 Glide 를 이용하여 이미지를 로딩하도록 합니다.

private fun setupDetailsOverviewRow() {  
...
	row.imageDrawable = ContextCompat.getDrawable(requireActivity(), R.drawable.default_background)  
	val width =  100  
	val height = 100  
	Glide.with(requireActivity())  
	    .load(cardItemData.imageUrl)  
	    .centerCrop()  
	    .error(R.drawable.default_background)  
	    .into(object : CustomTarget<Drawable>(width, height) {  
	        override fun onResourceReady(drawable: Drawable,  
	            transition: Transition<in Drawable>?) {  
	            row.imageDrawable = drawable  
	            mAdapter.notifyArrayItemRangeChanged(0, mAdapter.size())  
	        }  
	  
	        override fun onLoadCleared(placeholder: Drawable?) {  
	            TODO("Not yet implemented")  
	        }  
	    })

8.3.4 상세페이지에서 아이템로고와 액션, 상세설명 화면에 보이기

이전 단계에서는 단지 DetailsOverviewRow에 데이터만 할당했을뿐, 어떻게 화면에 표시할지 Presenter와 adapter를 지정하지 않았습니다.
Presenter는 FullWidthDetailsOverviewRowPresenter 라는 프레젠터를 사용할것입니다. 8.3.1 에서 ClassPresenterSelector라는 것을 사용하여 대표adapter에서 사용하겠다고 선언했었습니다.

mPresenterSelector =  ClassPresenterSelector() 
mAdapter =  ArrayObjectAdapter(mPresenterSelector) 
adapter = mAdapter

ClassPresenterSelector는 애덥터에게 Row데이터형식에 맞는 Presenter를 자동으로 선택되게 합니다.
따라서 ClassPresenterSelector 에 addClassPresenter(Row데이터클래스, Presenter)처럼 지정하면 Row데이터클래스형식으로 된 데이터는 Presenter형식으로 출력하도록 해줍니다.

  
  
private fun setupDetailsOverviewRow() {
    ...
    // cardItemData를 가지고 있는 DetailsOverviewRow를 선언
    val row = DetailsOverviewRow(cardItemData).  
    ,..
    // ClassPresenterSelector를 가지고있는 아답터에   DetailsOverviewRow형식의 데이터를 추가
    mAdapter.add(row)
    // ClassPresenterSelector에 DetailsOverviewRow와 MyDetailsDescriptionPresenter(밑에서 생성)을 연결 
	val detailsPresenter = FullWidthDetailsOverviewRowPresenter(MyDetailsDescriptionPresenter())    	
		detailsPresenter.setInitialState(FullWidthDetailsOverviewRowPresenter.STATE_SMALL);
	mPresenterSelector.addClassPresenter(
		DetailsOverviewRow::class.java, 
		detailsPresenter
	)
}
class MyDetailsDescriptionPresenter : AbstractDetailsDescriptionPresenter() {    
    override fun onBindDescription(  
        viewHolder: ViewHolder,  
        item: Any  
    ) {  
        val cardItemData = item as CardItemData  
  
        viewHolder.title.text = cardItemData.title  
  viewHolder.body.text = cardItemData.content  
  }  
}

이제 화면을 실행해 보면 로고이미지와 타이틀, 컨텐츠내용이 보일것입니다.

8.3.5 액션을 추가

DetailSupportFragment에는 액션리스트를 제공합니다. 위치는 Action list View에서 보여지며 아래와 같이 추가할수 있습니다.

  
private fun setupDetailsOverviewRow() {
    ...  
    setupActionButtons(row)
}
  
private fun setupActionButtons(row:DetailsOverviewRow) {  
    val actionAdapter = ArrayObjectAdapter()  
  
    actionAdapter.add(  
        Action(  
            1,  
            "액션1",  
            "액션라벨1")  
    )  
    actionAdapter.add(  
        Action(  
            2,  
            "액션2",  
            "액션라벨2")  
    )  
    actionAdapter.add(  
        Action(  
            3,  
            "액션3",  
            "액션라벨3")  
    )  
    row.actionsAdapter = actionAdapter  
  
}

8.3.5 액션을 선택하여 클릭시 영상 실행 화면으로 이동등 동작처리

액션리스트를 추가했으니 각 액션 아이템을 클릭시 영상 실행 화면으로 이동한다든지, 메시지를 출력하는 처리를 추가해 봅시다.

  
private fun setupDetailsOverviewRow() {
    ...  
    setupActionButtonListeners(detailsPresenter)
}  
  
private fun setupActionButtonListeners(detailsPresenter: FullWidthDetailsOverviewRowPresenter) {  
    detailsPresenter.onActionClickedListener = OnActionClickedListener { action ->  
  if (action.id == 1L) {  
            // val intent = Intent(requireActivity(), PlaybackActivity::class.java)  
 // startActivity(intent)  
 } else {  
            Toast.makeText(requireActivity(), action.toString(), Toast.LENGTH_SHORT).show()  
        }  
    }  
}

3개의 버튼중 2,3번 버튼을 클릭하면 Toast메시지가 나타납니다.

8.3.6 하단에 관련영상을 추가

상세보기 하단에 관련영상(또는 추천영상같은) 리스트를 추가해 봅시다.
다시 말하지만, ClassPresenterSelector를 사용하고 있기때문에 Row와 Presenter를 각각 새로 만들어서 ClassPresenterSelector에 추가하면 됩니다.
관련영상 리스트는 MainFragment에서 ListRowPresenter와 MyCardItemPresenter를 했던것과 같이 리스트,아이템화면을 표시할것입니다.

override fun onCreate(savedInstanceState: Bundle?) {
    ...      
    setupRelatedMovieListRow()
    adapter = mAdapter 
}

  
private fun setupRelatedMovieListRow() {  
    val subcategories = arrayOf("관련영상")  
  
    val listRowAdapter = ArrayObjectAdapter(MyCardItemPresenter())  
    listRowAdapter.add(  
        CardItemData(  
            title = "타이틀1",  
            content = "컨텐츠1",  
            imageUrl = "https://m.media-amazon.com/images/M/MV5BMjYzMGU0MzUtMTUxYy00MzE4LWFiMjctOTgwODg1ZjkwNTc0XkEyXkFqcGdeQXVyMTAyMjQ3NzQ1._V1_UX140_CR0,0,140,209_AL_.jpg"  
  )  
    )  
    listRowAdapter.add(  
        CardItemData(  
            title = "타이틀2",  
            content = "컨텐츠2",  
            imageUrl = "https://m.media-amazon.com/images/M/MV5BMWUwOThjYTAtZWYyYy00YjllLTkxYjEtNTJmNTI5N2M1NjkxXkEyXkFqcGdeQXVyOTU0NjY1MDM@._V1_UX140_CR0,0,140,209_AL_.jpg"  
  )  
    )  
  
    val header = HeaderItem(0, subcategories[0])  
    mAdapter.add(ListRow(header, listRowAdapter))  
    mPresenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter())  
}

8.3.7 하단영상 클릭시 이벤트 처리

MainFragment에서 했던것과 같이 MyItemViewClickedListener를 만들고 adapter의 클릭리스너에 대입하면 아이템의 데이터형식에 맞게 액션이 실행됩니다.

override fun onCreate(savedInstanceState: Bundle?) {
    ...        
    onItemViewClickedListener = MyItemViewClickedListener()    
    adapter = mAdapter 
}

  
private inner class MyItemViewClickedListener : OnItemViewClickedListener {  
    override fun onItemClicked(  
        itemViewHolder: Presenter.ViewHolder,  
        item: Any,  
        rowViewHolder: RowPresenter.ViewHolder,  
        row: Row  
    ) {  
        when(item) {  
            is CardItemData -> {  
                val intent = Intent(activity!!, DetailsActivity::class.java)  
                startActivity(intent)  
            }
        }  
    }  
}

실행해 보면 하단의 관련영상 클릭시 상세화면으로 다시 이동하는것을 볼수 있습니다.
현재는 상세화면이 고정된 데이터로 출력하고 있습니다. 뒤부분에서 Activity의 Bundle데이터를 이용하여 선택된 영상에 대한 상세정보와 동영상이 보이도록 수정할 것입니다.

9. PlaybackActivity 구현

선택된 영상을 보여주는 PlaybackActivity 를 구현해 봅시다. 이 액비비티는 PlaybackVideoFragment를 이용합니다.
PlaybackVideoFragment은 VideoSupportFragment를 계승하여 제목, 설명, 영상주소 등의 정보만 플레이어에 넘겨주면 알아서 플레이 화면이 구동되는 방식입니다.
지금은 PlaybackVideoFragment그래도 이용합시다.

package com.sugoigroup.myapplicationtv5  
  
import android.net.Uri  
import android.os.Bundle  
import androidx.leanback.app.VideoSupportFragment  
import androidx.leanback.app.VideoSupportFragmentGlueHost  
import androidx.leanback.media.MediaPlayerAdapter  
import androidx.leanback.media.PlaybackTransportControlGlue  
import androidx.leanback.widget.PlaybackControlsRow  
  
/** Handles video playback with media controls. */  
class PlaybackVideoFragment : VideoSupportFragment() {  
  
    private lateinit var mTransportControlGlue: PlaybackTransportControlGlue<MediaPlayerAdapter>  
  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
  
        val (title, content, _, videoUrl) = CardItemData(  
            title = "타이틀2",  
            content = "컨텐츠2",  
            imageUrl = "https://m.media-amazon.com/images/M/MV5BMWUwOThjYTAtZWYyYy00YjllLTkxYjEtNTJmNTI5N2M1NjkxXkEyXkFqcGdeQXVyOTU0NjY1MDM@._V1_UX140_CR0,0,140,209_AL_.jpg",  
            videoUrl = "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review.mp4"  
  )  
  
        val glueHost = VideoSupportFragmentGlueHost(this@PlaybackVideoFragment)  
        val playerAdapter = MediaPlayerAdapter(activity)  
        playerAdapter.setRepeatAction(PlaybackControlsRow.RepeatAction.INDEX_NONE)  
  
        mTransportControlGlue = PlaybackTransportControlGlue(getActivity(), playerAdapter)  
        mTransportControlGlue.host = glueHost  
        mTransportControlGlue.title = title  
        mTransportControlGlue.subtitle = content    
		mTransportControlGlue.isSeekEnabled = true
        mTransportControlGlue.playWhenPrepared()  
  
        playerAdapter.setDataSource(Uri.parse(videoUrl))  
    }  
  
    override fun onPause() {  
        super.onPause()  
        mTransportControlGlue.pause()  
    }  
}

CardItemData에 videoUrl을 추가하기 위해서 아래와 같이 수정합니다.

data class CardItemData(  
    val title:String,  
    val content: String,  
    val imageUrl: String,  
    val videoUrl: String? = null,  
): Serializable

실행해보면 구글의 영상이 플레이 되는것을 볼수 있습니다.

9.1 빨리감기, 뒤로감기 버튼추가하기

빨리감기/뒤로감기,자막 숨기기등의 기능을 콘트롤에 추가할수도있습니다.
아래와 같이 수정하고 PlaybackTransportControlGlue를 계승하는 사용자정의 클래스를 만들어서 지정하면 됩니다.

  
override fun onCreate(savedInstanceState: Bundle?) {
    ...  
    mTransportControlGlue = MyMediaPlayerGlue(context, playerAdapter)
}
 
  
inner class MyMediaPlayerGlue(context: Context?, adapter: MediaPlayerAdapter):  
    PlaybackTransportControlGlue<MediaPlayerAdapter>(context, adapter) {  
  
    private val actionRewind = PlaybackControlsRow.RewindAction(context)  
    private val actionFastForward = PlaybackControlsRow.FastForwardAction(context)  
    private val actionClosedCaptions = PlaybackControlsRow.ClosedCaptioningAction(context)  
  
    fun skipForward(millis: Long = 3000) =  
        playerAdapter.seekTo(if (playerAdapter.mediaPlayer.duration > 0) {  
            min(playerAdapter.duration, playerAdapter.currentPosition + millis)  
        } else {  
            playerAdapter.currentPosition + millis  
        })  
  
    fun skipBackward(millis: Long = 3000) =  
        playerAdapter.seekTo(max(0, playerAdapter.currentPosition - millis))  
  
    override fun onActionClicked(action: Action) = when(action) {  
        actionRewind -> skipBackward()  
        actionFastForward -> skipForward()  
        else -> super.onActionClicked(action)  
    }  
  
    override fun onCreatePrimaryActions(adapter: ArrayObjectAdapter) {  
        super.onCreatePrimaryActions(adapter)  
        adapter.add(actionRewind)  
        adapter.add(actionFastForward)  
        adapter.add(actionClosedCaptions)  
    }  
}

10. ErrorActivity 구현

MainActivity에서 첫번째메뉴의 아이템을 누르면 에러표시 화면으로 넘어가도록 합시다.
MyItemViewClickedListener에서 첫번째메뉴의 아이템들. 즉, String형식의 아이템을 선택했을때는 BrowseErrorActivity로 이동합니다.
BrowseErrorActivity에서는 두개의 Fragment를 이용하여 대기화면, 에러화면 을 순차적으로 보여주고 있습니다.

ErrorFragment는 ErrorSupportFragment를 계승해서 타이틀과 에러메시지내용 버튼글씨와 클릭처리를 하고 있습니다.

ETC

이밖에 SearchSupportFragment, GuidedStepSupportFragment 등이 있습니다.
연습해 보시기 바랍니다.

github sample :
https://github.com/sugoigroup/AndroidTvAppPractiveSample

Layout Tip

VerticalGridView 의 마진,정렬

기본적으로 VerticalGridView는 Recycle을 계승받는다.
추가로 몇개의 속성이 있다.

  • itemAlignmentOffset:입력값만큼 각 아이템들을 좌(또는 상, 세로리스트와 그리드만 지원) 으로 오프셋한다. (itemAlignmentOffsetPercent랑 같이 계산한다. 예르 들어 아이템의 절반에서 10dp 이동한 만큼 부터 표시하고 싶은때는 OffsetPercent=50%, Offset = 10)
  • itemAlignmentOffsetPercent: 각 아이템들의 크기에 대한 %만큼 좌(또는 상, 세로리스트와 그리드만 지원) 으로 오프셋한다.
  • windowAlignmentOffset : 양수 일때는 지정된 값만큼 의 좌(또는 상)으로부터의 거리만큼 아이템이 선택되었을때 스크롤을 시작한다. 음수일때는 우(하단)의 거리로 사용한다. 즉 커서는 고정된채 다음 아이템이 커서의 위치에 스크롤되서 오는 표현이다.
  • windowAlignmentOffsetPercent : windowAlignmentOffset값의 몇%일때 리스트를 스크롤 시킬것인지 결정한다. (에를 들어, 가로 리스트의 화면상의 길이가 100 일때, windowAlignmentOffset=30 ,windowAlignmentOffsetPercent=1f 이면 양수니까 리스트의 좌측 30거리를 벗어나면 포커스는 30거리에 유지된채 리스트가 이동한다. 물론 반대쪽 아이템이 리스트의 마지막 아이템일떄는 커서가 이동한다. 리스트윈도우 크기에 따라 다르지만 리스트가 더이상 스크롤 되지 못하는 조(마지막 아이템이 화면 끝에 보인다는 조건) 이라면 아마도 83 - 100 또는 60 - 100 까지는 커서가 이동되는 것이다.
  • windowAlignment
  1. WINDOW_ALIGN_LOW_EDGE : 리스트뷰의 좌 또는 상단을 기준으로 아이템이 잘리지 않고 온전하게 표시되도록 해준다. 즉 아이템리스트가 많을 경우 우(또는 하단)의 아이템들은 잘려져서 보이는 경우가 있다.
  2. WINDOW_ALIGN_HIGH_EDGE: Low와 반대로 우(또는 하단)의 아이템이 제대로 보여질 것이다.물론 처음 표시때는 0번아이템은 제대로 보일것이고 스크롤 하면 좌(또는 상단)의 아이템은 잘려져서 보이는 경우가 있다.
  3. WINDOW_ALIGN_BOTH_EDGE(기본값):릿리스트 윈도우내에 시작점과 끝점에 아이템들이 온전히 보이도록 한다.
  4. WINDOW_ALIGN_NO_EDGE(없음) :정렬기준이 없으므로 마지막 아이템은 뒤에는 여백이 생길수있다.

자 그럼 실제 예를 보자
리스트영역보다 화면표시영역이 작은 세로 리스트 화면일때 일단 아이템을 알래로 선택하여 나갈때 두번째 아이템커서 위치에서 아이템들이 스크롤되어 올라와서 선택되어 지다가 화면표시영역에 마지막 리스트 아이템까지 표시가 되었다면 이제는 커서가 나래로 내려가서 끝까지 선택이 가능하도록 하자.

itemAlignmentOffset = 0 -> 아이템들은 그냥 그대로 표시할꺼다
itemAlignmentOffsetPercent = VerticalGridView.ITEM_ALIGN_OFFSET_PERCENT_DISABLED ->역시 아이템들을 화면에서 짤려 보이지 않게 표시할꺼다.
windowAlignmentOffset = 50 -> 아이템높이가 50이라고 가정할때 50만큼 띄울꺼니까 커서는 항상 위에서 부터 두번쨰에 위치한다.
windowAlignmentOffsetPercent = WINDOW_ALIGN_OFF_PERCENT_DISABLED ->windowAlignmentOffset값만 이용할꺼다.
setPadding(0,0,0,20) -> 리스트화면의 하단에 간격을 띄운다.
clipTopadding = false -> 각 아이템의 패팅값을 무효화 한다(리스트가 마지막 목록이 보이는 장면에서만 패딩이 적용된다.즉, 중간 지점에서는 하단의 패딩이 없다가 마지막 리스트 아이템 밑에만 패딩이 붙는다. 이걸 없애면 리스트 하단에 무조건 패딩이 붙는다.)