TV에서 실행되는 미디어 앱은 사용자가 콘텐츠 서비스를 탐색하고, 콘텐츠를 선택하고, 콘텐츠 재생을 시작할 수 있도록 해야 합니다. 콘텐츠 탐색 환경은 단순하고 직관적이어야 하며 시각적으로 즐겁고 흥미롭어야 합니다.
이 가이드에서는 Leanback AndroidX 라이브러리에서 제공하는 클래스를 사용하여 앱 미디어 카탈로그에서 음악이나 동영상을 탐색하기 위한 사용자 인터페이스를 구현하는 방법을 설명합니다.
참고: 여기서 보여주는 구현 예에서는 지원 중단된 BrowseFragment
클래스가 아닌 BrowseSupportFragment
를 사용합니다. BrowseSupportFragment
는 AndroidX
Fragment
클래스를 확장하여 여러 기기와 Android 버전에서 일관된 동작을 보장하는 데 도움이 됩니다.
![앱 기본 화면](https://developer.android.com/static/images/tv/app-browse.png?authuser=9&hl=ko)
그림 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) } ...
Java
public class MainActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); } ...
BrowseSupportFragment
메서드는 동영상 데이터와 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 header 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() } ...
Java
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 header 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와 같이 카테고리 이름 옆에 아이콘을 표시하는 이미지 뷰를 포함하는 방법을 보여줍니다.
![앱 기본 화면](https://developer.android.com/static/images/tv/custom-head.png?authuser=9&hl=ko)
그림 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 } }
Java
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 // ... }
Java
@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() } })
Java
setHeaderPresenterSelector(new PresenterSelector() { @Override public Presenter getPresenter(Object o) { return new IconHeaderItemPresenter(); } });
전체 예는 Leanback 샘플 앱을 참고하세요.
헤더 숨기기 또는 사용 중지
카테고리가 충분하지 않아 스크롤 가능한 목록을 요구할 때와 같이 행 헤더가 표시되지 않게 하려는 경우가 있습니다. 행 헤더를 숨기거나 사용 중지하려면 프래그먼트의 onActivityCreated()
메서드 중에 BrowseSupportFragment.setHeadersState()
메서드를 호출합니다. setHeadersState()
메서드는 다음 상수 중 하나를 매개변수로 지정하여 탐색 프래그먼트에서 헤더의 초기 상태를 설정합니다.
HEADERS_ENABLED
: 탐색 프래그먼트 활동이 만들어지면 기본적으로 헤더가 사용 설정되고 표시됩니다. 헤더는 이 페이지의 그림 1 및 그림 2와 같이 표시됩니다.HEADERS_HIDDEN
: 탐색 프래그먼트 활동이 만들어지면 기본적으로 헤더가 사용 설정되고 숨겨집니다. 화면의 헤더 섹션은 카드 보기 제공의 그림과 같이 접혀 있습니다. 사용자가 접힌 헤더 섹션을 선택하여 펼칠 수 있습니다.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 } }
Java
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 }
Java
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); }
이 예시는 어댑터의 정적 구현을 보여줍니다. 일반적인 미디어 탐색 애플리케이션은 온라인 데이터베이스나 웹 서비스의 데이터를 사용합니다. 웹에서 검색한 데이터를 사용하는 탐색 애플리케이션의 예는 Leanback 샘플 앱을 참고하세요.
배경 업데이트
TV의 미디어 탐색 앱에 시각적 흥미를 더하기 위해 사용자가 콘텐츠를 탐색할 때 배경 이미지를 업데이트할 수 있습니다. 이 기법을 사용하면 앱과의 상호작용을 더 극적이고 재미있게 만들 수 있습니다.
Leanback 지원 라이브러리는 TV 앱 활동의 배경을 변경하는 BackgroundManager
클래스를 제공합니다. 다음 예는 TV 앱 활동 내에서 백그라운드를 업데이트하는 간단한 메서드를 만드는 방법을 보여줍니다.
Kotlin
protected fun updateBackground(drawable: Drawable) { BackgroundManager.getInstance(this).drawable = drawable }
Java
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() } }
Java
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(); } } }; }
참고: 이전 구현은 설명을 위한 간단한 예입니다. 자체 앱에서 이 함수를 만들 때는 별도의 스레드에서 백그라운드 업데이트 작업을 실행하여 성능을 향상하세요. 또한 사용자가 항목을 스크롤하는 데 대한 응답으로 백그라운드를 업데이트하려는 경우 사용자가 항목에 멈출 때까지 백그라운드 이미지 업데이트를 지연하는 시간을 추가합니다. 이 기법을 사용하면 과도한 배경 이미지 업데이트를 방지할 수 있습니다.