TV에서 실행되는 미디어 앱은 사용자가 콘텐츠 서비스를 탐색하고, 콘텐츠를 선택하고, 콘텐츠 재생을 시작할 수 있게 해야 합니다. 이런 유형의 앱 콘텐츠 탐색 경험은 단순하고 직관적이면서도 시각적으로 즐겁고 흥미로워야 합니다.
이 과정에서는 Leanback AndroidX 라이브러리에서 제공하는 클래스를 사용하여 앱 미디어 카탈로그에서 음악이나 동영상을 탐색하기 위한 사용자 인터페이스를 구현하는 방법을 설명합니다.
참고: 여기서 보여주는 구현 예에서는 지원 중단된 BrowseFragment
클래스가 아닌 BrowseSupportFragment
를 사용합니다. BrowseSupportFragment
는 AndroidX Fragment
클래스를 확장하여 기기 및 Android 버전 전체에서 일관된 동작을 보장합니다.

그림 1. Leanback 샘플 앱 탐색 프래그먼트는 동영상 카탈로그 데이터를 표시합니다.
미디어 탐색 레이아웃 만들기
Leanback 라이브러리의 BrowseSupportFragment
클래스를 사용하면 최소한의 코드로 미디어 항목의 카테고리와 행을 탐색하기 위한 기본 레이아웃을 만들 수 있습니다. 다음 예에서는 BrowseSupportFragment
객체가 포함된 레이아웃을 만드는 방법을 보여줍니다.
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/main_frame" android:layout_width="match_parent" android:layout_height="match_parent"> <fragment android:name="com.example.android.tvleanback.ui.MainFragment" android:id="@+id/main_browse_fragment" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
애플리케이션의 기본 활동에서는 다음 예와 같이 이 뷰를 설정합니다.
Kotlin
class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.main) } ...
자바
public class MainActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); } ...
BrowseSupportFragment
메서드는 동영상 데이터 및 UI 요소로 뷰를 채우고 아이콘, 제목 및 카테고리 헤더 사용 설정 여부와 같은 레이아웃 매개변수를 설정합니다.
- UI 요소 설정에 관한 자세한 정보는 UI 요소 설정을 참조하세요.
- 헤더 숨기기에 관한 자세한 내용은 헤더 숨기기 또는 사용 중지를 참조하세요.
BrowseSupportFragment
메서드를 구현하는 애플리케이션의 서브클래스도 다음 예와 같이 UI 요소에서 사용자 작업에 관한 이벤트 리스너를 설정하고 백그라운드 관리자를 준비합니다.
Kotlin
class MainFragment : BrowseSupportFragment(), LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadVideoData() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) prepareBackgroundManager() setupUIElements() setupEventListeners() } ... private fun prepareBackgroundManager() { backgroundManager = BackgroundManager.getInstance(activity).apply { attach(activity?.window) } defaultBackground = resources.getDrawable(R.drawable.default_background) metrics = DisplayMetrics() activity?.windowManager?.defaultDisplay?.getMetrics(metrics) } private fun setupUIElements() { badgeDrawable = resources.getDrawable(R.drawable.videos_by_google_banner) // Badge, when set, takes precedent over title title = getString(R.string.browse_title) headersState = BrowseSupportFragment.HEADERS_ENABLED isHeadersTransitionOnBackEnabled = true // set headers background color brandColor = ContextCompat.getColor(requireContext(), R.color.fastlane_background) // set search icon color searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.search_opaque) } private fun loadVideoData() { VideoProvider.setContext(activity) videosUrl = getString(R.string.catalog_url) loaderManager.initLoader(0, null, this) } private fun setupEventListeners() { setOnSearchClickedListener { Intent(activity, SearchActivity::class.java).also { intent -> startActivity(intent) } } onItemViewClickedListener = ItemViewClickedListener() onItemViewSelectedListener = ItemViewSelectedListener() } ...
자바
public class MainFragment extends BrowseSupportFragment implements LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> { } ... @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); loadVideoData(); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); prepareBackgroundManager(); setupUIElements(); setupEventListeners(); } ... private void prepareBackgroundManager() { backgroundManager = BackgroundManager.getInstance(getActivity()); backgroundManager.attach(getActivity().getWindow()); defaultBackground = getResources() .getDrawable(R.drawable.default_background); metrics = new DisplayMetrics(); getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); } private void setupUIElements() { setBadgeDrawable(getActivity().getResources() .getDrawable(R.drawable.videos_by_google_banner)); // Badge, when set, takes precedent over title setTitle(getString(R.string.browse_title)); setHeadersState(HEADERS_ENABLED); setHeadersTransitionOnBackEnabled(true); // set headers background color setBrandColor(ContextCompat.getColor(requireContext(), R.color.fastlane_background)); // set search icon color setSearchAffordanceColor(ContextCompat.getColor(requireContext(), R.color.search_opaque)); } private void loadVideoData() { VideoProvider.setContext(getActivity()); videosUrl = getString(R.string.catalog_url); getLoaderManager().initLoader(0, null, this); } private void setupEventListeners() { setOnSearchClickedListener(new View.OnClickListener() { @Override public void onClick(View view) { Intent intent = new Intent(getActivity(), SearchActivity.class); startActivity(intent); } }); setOnItemViewClickedListener(new ItemViewClickedListener()); setOnItemViewSelectedListener(new ItemViewSelectedListener()); } ...
UI 요소 설정
위 샘플에서 비공개 메서드 setupUIElements()
는 BrowseSupportFragment
메서드를 여러 개 호출하여 미디어 카탈로그 브라우저의 스타일을 지정합니다.
setBadgeDrawable()
은 그림 1 및 그림 2와 같이 지정된 드로어블 리소스를 탐색 프래그먼트 오른쪽 위 모서리에 배치합니다.setTitle()
도 호출되면 이 메서드는 제목 문자열을 드로어블 리소스로 바꿉니다. 드로어블 리소스 높이는 52dp여야 합니다.setTitle()
은setBadgeDrawable()
이 호출되지 않는 한 탐색 프래그먼트 오른쪽 위 모서리에 제목 문자열을 설정합니다.setHeadersState()
및setHeadersTransitionOnBackEnabled()
는 헤더를 숨기거나 사용 중지합니다. 자세한 내용은 헤더 숨기기 또는 사용 중지를 참조하세요.setBrandColor()
는 탐색 프래그먼트에서 UI 요소의 배경 색상, 특히 헤더 섹션 배경 색상을 지정된 색상 값으로 설정합니다.setSearchAffordanceColor()
는 검색 아이콘의 색상을 지정된 색상 값으로 설정합니다. 검색 아이콘은 그림 1 및 그림 2와 같이 탐색 프래그먼트의 왼쪽 위 모서리에 나타납니다.
헤더 뷰 맞춤설정
그림 1의 탐색 프래그먼트에서는 왼쪽 창에 동영상 카테고리 이름(행 헤더)이 나열되어 있습니다. 텍스트 뷰는 동영상 데이터베이스에서 이 카테고리 이름을 표시합니다. 헤더를 맞춤설정하여 더 복잡한 레이아웃에 추가 뷰를 포함할 수 있습니다. 다음 섹션에서는 그림 2와 같이 카테고리 이름 옆에 아이콘을 표시하는 이미지 뷰를 포함하는 방법을 보여줍니다.

그림 2. 아이콘과 텍스트 라벨이 모두 포함된 탐색 프래그먼트의 행 헤더입니다.
행 헤더의 레이아웃은 다음과 같이 정의됩니다.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/header_icon" android:layout_width="32dp" android:layout_height="32dp" /> <TextView android:id="@+id/header_label" android:layout_marginTop="6dp" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout>
Presenter
를 사용하고 추상 메서드를 구현하여 뷰 홀더를 생성, 결합 및 결합 해제합니다. 다음 예에서는 두 개의 뷰, 즉 ImageView
및 TextView
로 뷰 홀더를 결합하는 방법을 보여줍니다.
Kotlin
class IconHeaderItemPresenter : Presenter() { override fun onCreateViewHolder(viewGroup: ViewGroup): Presenter.ViewHolder { val view = LayoutInflater.from(viewGroup.context).run { inflate(R.layout.icon_header_item, null) } return Presenter.ViewHolder(view) } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, o: Any) { val headerItem = (o as ListRow).headerItem val rootView = viewHolder.view rootView.findViewById<ImageView>(R.id.header_icon).apply { rootView.resources.getDrawable(R.drawable.ic_action_video, null).also { icon -> setImageDrawable(icon) } } rootView.findViewById<TextView>(R.id.header_label).apply { text = headerItem.name } } override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { // no op } }
자바
public class IconHeaderItemPresenter extends Presenter { @Override public ViewHolder onCreateViewHolder(ViewGroup viewGroup) { LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext()); View view = inflater.inflate(R.layout.icon_header_item, null); return new ViewHolder(view); } @Override public void onBindViewHolder(ViewHolder viewHolder, Object o) { HeaderItem headerItem = ((ListRow) o).getHeaderItem(); View rootView = viewHolder.view; ImageView iconView = (ImageView) rootView.findViewById(R.id.header_icon); Drawable icon = rootView.getResources().getDrawable(R.drawable.ic_action_video, null); iconView.setImageDrawable(icon); TextView label = (TextView) rootView.findViewById(R.id.header_label); label.setText(headerItem.getName()); } @Override public void onUnbindViewHolder(ViewHolder viewHolder) { // no op } }
헤더는 D패드를 사용하여 헤더를 스크롤할 수 있도록 포커스 가능해야 합니다. 두 가지 대안은 다음과 같습니다.
- 다음과 같이
onBindViewHolder()
에서 뷰를 포커스 가능하게 설정합니다.Kotlin
override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, o: Any) { val headerItem = (o as ListRow).headerItem val rootView = viewHolder.view rootView.focusable = View.FOCUSABLE //... }
자바
@Override public void onBindViewHolder(ViewHolder viewHolder, Object o) { HeaderItem headerItem = ((ListRow) o).getHeaderItem(); View rootView = viewHolder.view; rootView.setFocusable(View.FOCUSABLE) // Allows the D-Pad to navigate to this header item //... }
- 다음과 같이 레이아웃을 포커스 가능하게 설정합니다.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" ... android:focusable="true">
마지막으로, 카탈로그 브라우저를 표시하는 BrowseSupportFragment
구현에서 다음 예와 같이 setHeaderPresenterSelector()
메서드를 사용하여 행 헤더의 프레젠터를 설정합니다.
Kotlin
setHeaderPresenterSelector(object : PresenterSelector() { override fun getPresenter(o: Any): Presenter { return IconHeaderItemPresenter() } })
자바
setHeaderPresenterSelector(new PresenterSelector() { @Override public Presenter getPresenter(Object o) { return new IconHeaderItemPresenter(); } });
전체 예는 Android TV GitHub 저장소에서 Android Leanback 샘플 앱을 참조하세요.
헤더 숨기기 또는 사용 중지
행 헤더가 표시되는 것을 원하지 않는 경우도 있습니다. 예를 들어 스크롤 가능한 목록이 필요한 카테고리가 충분하지 않을 때입니다. 행 헤더를 숨기거나 사용 중지하려면 프래그먼트의 onActivityCreated()
메서드 중에 BrowseSupportFragment.setHeadersState()
메서드를 호출합니다. setHeadersState()
메서드는 다음 상수 중 하나가 매개변수로 주어지면 탐색 프래그먼트에서 초기 헤더 상태를 설정합니다.
HEADERS_ENABLED
- 탐색 프래그먼트 활동이 만들어지는 경우 기본적으로 헤더가 사용 설정되고 표시됩니다. 헤더는 이 페이지의 그림 1 및 그림 2와 같이 표시됩니다.HEADERS_HIDDEN
- 탐색 프래그먼트 활동이 만들어지는 경우 기본적으로 헤더가 사용 설정되고 숨겨집니다. 화면의 헤더 섹션은 카드 보기 제공의 그림 1과 같이 접혀져 있습니다. 사용자가 접혀진 헤더 섹션을 선택하면 섹션이 펼쳐집니다.HEADERS_DISABLED
- 탐색 프래그먼트 활동이 만들어지는 경우 기본적으로 헤더가 사용 중지로 설정되고 표시되지 않습니다.
HEADERS_ENABLED
또는 HEADERS_HIDDEN
이 설정되면 setHeadersTransitionOnBackEnabled()
를 호출하여 행의 선택된 콘텐츠 항목에서 행 헤더로 다시 이동하는 동작을 지원할 수 있습니다. 이 동작은 메서드를 호출하지 않아도 기본적으로 사용 설정되지만 직접 뒤로 이동을 처리하려면 false
값을 setHeadersTransitionOnBackEnabled()
에 전달하고 자체 백 스택 처리를 구현해야 합니다.
미디어 목록 표시
BrowseSupportFragment
클래스를 통해 어댑터 및 프레젠터를 사용하여 미디어 카탈로그의 탐색 가능한 미디어 콘텐츠 카테고리와 미디어 항목을 정의하고 표시할 수 있습니다. 어댑터를 사용하면 미디어 카탈로그 정보가 포함된 로컬 또는 온라인 데이터 소스에 연결할 수 있습니다.
어댑터는 프레젠터를 사용하여 뷰를 만들고 화면에 항목을 표시하도록 이러한 뷰에 데이터를 결합합니다.
다음 코드 예에서는 문자열 데이터를 표시하기 위한 Presenter
의 구현을 보여줍니다.
Kotlin
private const val TAG = "StringPresenter" class StringPresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder { val textView = TextView(parent.context).apply { isFocusable = true isFocusableInTouchMode = true background = parent.resources.getDrawable(R.drawable.text_bg) } return Presenter.ViewHolder(textView) } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) { (viewHolder.view as TextView).text = item.toString() } override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { // no op } }
자바
public class StringPresenter extends Presenter { private static final String TAG = "StringPresenter"; public ViewHolder onCreateViewHolder(ViewGroup parent) { TextView textView = new TextView(parent.getContext()); textView.setFocusable(true); textView.setFocusableInTouchMode(true); textView.setBackground( parent.getResources().getDrawable(R.drawable.text_bg)); return new ViewHolder(textView); } public void onBindViewHolder(ViewHolder viewHolder, Object item) { ((TextView) viewHolder.view).setText(item.toString()); } public void onUnbindViewHolder(ViewHolder viewHolder) { // no op } }
미디어 항목에 관한 프레젠터 클래스를 구성한 후 어댑터를 빌드하고 그 어댑터를 BrowseSupportFragment
에 연결하여 사용자가 항목을 탐색할 수 있게 화면에 표시할 수 있습니다. 다음 코드 예에서는 이전 코드 예에 나온 StringPresenter
클래스를 사용하여 카테고리와 카테고리의 항목을 표시하도록 어댑터를 구성하는 방법을 보여줍니다.
Kotlin
private const val NUM_ROWS = 4 ... private lateinit var rowsAdapter: ArrayObjectAdapter override fun onCreate(savedInstanceState: Bundle?) { ... buildRowsAdapter() } private fun buildRowsAdapter() { rowsAdapter = ArrayObjectAdapter(ListRowPresenter()) for (i in 0 until NUM_ROWS) { val listRowAdapter = ArrayObjectAdapter(StringPresenter()).apply { add("Media Item 1") add("Media Item 2") add("Media Item 3") } HeaderItem(i.toLong(), "Category $i").also { header -> rowsAdapter.add(ListRow(header, listRowAdapter)) } } browseSupportFragment.adapter = rowsAdapter }
자바
private ArrayObjectAdapter rowsAdapter; private static final int NUM_ROWS = 4; @Override protected void onCreate(Bundle savedInstanceState) { ... buildRowsAdapter(); } private void buildRowsAdapter() { rowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); for (int i = 0; i < NUM_ROWS; ++i) { ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter( new StringPresenter()); listRowAdapter.add("Media Item 1"); listRowAdapter.add("Media Item 2"); listRowAdapter.add("Media Item 3"); HeaderItem header = new HeaderItem(i, "Category " + i); rowsAdapter.add(new ListRow(header, listRowAdapter)); } browseSupportFragment.setAdapter(rowsAdapter); }
이 예시는 어댑터의 정적 구현을 보여줍니다. 일반적인 미디어 탐색 애플리케이션은 온라인 데이터베이스나 웹 서비스의 데이터를 사용합니다. 웹에서 검색한 데이터를 사용하는 탐색 애플리케이션의 예는 Android TV GitHub 저장소에서 Android Leanback 샘플 앱을 참조하세요.
배경 업데이트
TV의 미디어 탐색 앱에 시각적 흥미를 더하기 위해 사용자가 콘텐츠를 탐색할 때 배경 이미지를 업데이트할 수 있습니다. 이 기술은 앱과의 상호작용을 더욱 극적이고 재미있게 만들어줍니다.
Leanback 지원 라이브러리는 TV 앱 활동의 배경을 변경하는 BackgroundManager
클래스를 제공합니다. 다음 예는 TV 앱 활동에서 배경을 업데이트하는 간단한 메서드를 만드는 방법을 보여줍니다.
Kotlin
protected fun updateBackground(drawable: Drawable) { BackgroundManager.getInstance(this).drawable = drawable }
자바
protected void updateBackground(Drawable drawable) { BackgroundManager.getInstance(this).setDrawable(drawable); }
기존 미디어 탐색 앱은 대부분 사용자가 미디어 목록을 탐색할 때 배경을 자동으로 업데이트합니다. 이를 위해서 선택 리스너를 설정하고 사용자의 현재 선택에 따라 배경을 자동으로 업데이트합니다. 다음 예에서는 OnItemViewSelectedListener
클래스를 설정하여 선택 이벤트를 포착하고 배경을 업데이트하는 방법을 보여줍니다.
Kotlin
protected fun clearBackground() { BackgroundManager.getInstance(this).drawable = defaultBackground } protected fun getDefaultItemViewSelectedListener(): OnItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ -> if (item is Movie) { item.getBackdropDrawable().also { background -> updateBackground(background) } } else { clearBackground() } }
자바
protected void clearBackground() { BackgroundManager.getInstance(this).setDrawable(defaultBackground); } protected OnItemViewSelectedListener getDefaultItemViewSelectedListener() { return new OnItemViewSelectedListener() { @Override public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { if (item instanceof Movie ) { Drawable background = ((Movie)item).getBackdropDrawable(); updateBackground(background); } else { clearBackground(); } } }; }
참고: 위의 구현은 기능을 설명하기 위해 제시된 간단한 예입니다. 자체 앱에서 이 기능을 만드는 경우 성능을 높이기 위해 별도의 스레드에서 배경 업데이트 작업을 실행하는 것이 좋습니다. 또한, 사용자가 항목을 스크롤하는 데 대한 응답으로 배경을 업데이트하고자 한다면 사용자가 한 항목에 멈출 때까지 배경 이미지 업데이트를 지연시키도록 시간을 추가하는 것을 고려해보세요. 이러한 방법을 사용하면 과도한 배경 이미지 업데이트를 방지할 수 있습니다.