카탈로그 브라우저 만들기

TV에서 실행되는 미디어 앱은 사용자가 콘텐츠 서비스를 탐색하고, 콘텐츠를 선택하고, 콘텐츠 재생을 시작할 수 있도록 해야 합니다. 콘텐츠 탐색 환경은 단순하고 직관적이어야 하며 시각적으로 즐겁고 흥미롭어야 합니다.

이 가이드에서는 Leanback AndroidX 라이브러리에서 제공하는 클래스를 사용하여 앱 미디어 카탈로그에서 음악이나 동영상을 탐색하기 위한 사용자 인터페이스를 구현하는 방법을 설명합니다.

참고: 여기서 보여주는 구현 예에서는 지원 중단된 BrowseFragment 클래스가 아닌 BrowseSupportFragment를 사용합니다. BrowseSupportFragmentAndroidX 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)
    }
...

Java

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 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와 같이 카테고리 이름 옆에 아이콘을 표시하는 이미지 뷰를 포함하는 방법을 보여줍니다.

앱 기본 화면

그림 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를 사용하고 추상 메서드를 구현하여 뷰 홀더를 생성, 결합, 결합 해제합니다. 다음 예는 ImageViewTextView의 두 뷰로 뷰 홀더를 결합하는 방법을 보여줍니다.

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()를 호출하여 행의 선택된 콘텐츠 항목에서 행 헤더로 다시 이동하도록 지원할 수 있습니다. 이 기능은 메서드를 호출하지 않으면 기본적으로 사용 설정됩니다. 뒤로 이동을 직접 처리하려면 falsesetHeadersTransitionOnBackEnabled()에 전달하고 자체 백 스택 처리를 구현합니다.

미디어 목록 표시

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();
            }
        }
    };
}

참고: 이전 구현은 설명을 위한 간단한 예입니다. 자체 앱에서 이 함수를 만들 때는 별도의 스레드에서 백그라운드 업데이트 작업을 실행하여 성능을 향상하세요. 또한 사용자가 항목을 스크롤하는 데 대한 응답으로 백그라운드를 업데이트하려는 경우 사용자가 항목에 멈출 때까지 백그라운드 이미지 업데이트를 지연하는 시간을 추가합니다. 이 기법을 사용하면 과도한 배경 이미지 업데이트를 방지할 수 있습니다.