Register now for Android Dev Summit 2019!

카탈로그 브라우저 만들기

TV에서 실행되는 미디어 앱은 사용자가 콘텐츠 서비스를 탐색하고, 콘텐츠를 선택하고, 콘텐츠를 재생할 수 있게 해야 합니다. 이런 유형의 앱 콘텐츠 탐색 경험은 단순하고 직관적이면서도 시각적으로 즐겁고 흥미로워야 합니다.

이 과정에서는 v17 Leanback 지원 라이브러리를 통해 제공되는 클래스를 사용하여 앱 미디어 카탈로그에서 음악이나 비디오를 탐색하기 위한 사용자 인터페이스를 구현하는 방법을 설명합니다.

앱 기본 화면

그림 1. Leanback 샘플 앱 탐색 프래그먼트는 비디오 카탈로그 데이터를 보여줍니다.

미디어 탐색 레이아웃 만들기

Leanback 라이브러리의 BrowseFragment 클래스를 사용하면 최소한의 코드로 미디어 항목의 카테고리와 행을 탐색하기 위한 기본 레이아웃을 만들 수 있습니다. 다음 예에서는 BrowseFragment 객체가 포함된 레이아웃을 만드는 방법을 보여줍니다.

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

BrowseFragment 메서드는 비디오 데이터와 UI 요소로 뷰를 채우고 아이콘, 제목, 카테고리 헤더 사용 여부 등의 레이아웃 매개변수를 설정합니다.

BrowseFragment 메서드를 구현하는 애플리케이션의 서브클래스도, 다음 예와 같이, UI 요소에서 사용자 작업에 관한 이벤트 리스너를 설정하고 백그라운드 관리자를 준비합니다.

Kotlin

    class MainFragment : BrowseFragment(),
            LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> {
        ...
        override fun onActivityCreated(savedInstanceState: Bundle?) {
            super.onActivityCreated(savedInstanceState)

            loadVideoData()

            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 = resources.getColor(R.color.fastlane_background)
            // set search icon color
            searchAffordanceColor = resources.getColor(R.color.search_opaque)
        }

        private fun loadVideoData() {
            VideoProvider.setContext(activity)
            videosUrl = resources.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 BrowseFragment implements
            LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> {
    }
    ...

        @Override
        public void onActivityCreated(Bundle savedInstanceState) {
            super.onActivityCreated(savedInstanceState);

            loadVideoData();

            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(getResources().getColor(R.color.fastlane_background));
            // set search icon color
            setSearchAffordanceColor(getResources().getColor(R.color.search_opaque));
        }

        private void loadVideoData() {
            VideoProvider.setContext(getActivity());
            videosUrl = getActivity().getResources().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()는 여러 개의 BrowseFragment 메서드를 호출하여 미디어 카탈로그 브라우저 스타일을 지정합니다.

  • 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
        }
    }
    

자바

    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">

마지막으로, 카탈로그 브라우저를 표시하는 BrowseFragment 구현에서 아래 예와 같이 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();
        }
    });
    

전체 예는 Leanback 샘플의 IconHeaderItemPresenter를 참조하세요.

헤더 숨기기 또는 사용 안함

행 헤더가 표시되는 것을 원하지 않는 경우도 있습니다. 예를 들어 카테고리가 많지 않으면 스크롤 가능한 목록이 필요하지 않을 수 있습니다. 행 헤더를 숨기거나 사용 안함으로 설정하려면 프래그먼트의 onActivityCreated() 메서드에서 BrowseFragment.setHeadersState() 메서드를 호출합니다. 탐색 프래그먼트에서 setHeadersState() 메서드에 아래 상수 중 하나를 매개변수로 지정하여 헤더의 최초 상태를 설정합니다.

  • HEADERS_ENABLED - 탐색 프래그먼트 활동이 만들어지는 경우 기본적으로 헤더가 사용 설정되고 표시됩니다. 헤더는 이 페이지의 그림 1 및 그림 2와 같이 표시됩니다.
  • HEADERS_HIDDEN - 탐색 프래그먼트 활동이 만들어지는 경우 기본적으로 헤더가 사용 설정되고 숨겨집니다. 화면의 헤더 섹션은 카드 뷰 제공그림 1과 같이 축소되어 있습니다. 사용자가 축소된 헤더 섹션이 펼쳐지도록 선택할 수 있습니다.
  • HEADERS_DISABLED - 탐색 프래그먼트 활동이 만들어지는 경우 기본적으로 헤더가 사용 안함으로 설정되고 표시되지 않습니다.

HEADERS_ENABLED 또는 HEADERS_HIDDEN이 설정되면 setHeadersTransitionOnBackEnabled()를 호출하여 행의 선택된 콘텐츠 항목에서 행 헤더로 돌아가는 뒤로 이동을 지원할 수 있습니다. 이 동작은 해당 메서드를 호출하지 않아도 기본적으로 사용되지만 직접 뒤로 이동을 처리하려면 false 값을 setHeadersTransitionOnBackEnabled()에 전달하여 고유한 백 스택 처리를 구현해야 합니다.

미디어 목록 표시

BrowseFragment 클래스를 사용하면 어댑터와 프레젠터를 사용하여 미디어 카탈로그의 탐색 가능한 미디어 콘텐츠 카테고리와 미디어 항목을 정의하고 표시할 수 있습니다. 어댑터를 사용하면 미디어 카탈로그 정보가 포함된 로컬 또는 온라인 데이터 소스에 연결할 수 있습니다. 어댑터는 프레젠터를 사용하여 뷰를 만들고 화면에 항목을 표시하도록 해당 뷰에 데이터를 바인딩합니다.

다음 코드 예에서는 문자열 데이터를 표시하기 위한 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.getContext().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
        }
    }
    

미디어 항목에 관한 프레젠터 클래스를 구성한 후 어댑터를 빌드하고 그 어댑터를 BrowseFragment에 첨부하여 사용자가 해당 항목을 탐색할 수 있게 화면에 표시할 수 있습니다. 다음 코드 예에서는 이전 코드 예에 나온 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))
            }
        }
        browseFragment.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));
        }

        browseFragment.setAdapter(rowsAdapter);
    }
    

이 예시는 어댑터의 정적 구현을 보여줍니다. 일반적인 미디어 탐색 애플리케이션은 온라인 데이터베이스나 웹 서비스의 데이터를 사용합니다. 웹에서 검색한 데이터를 사용하는 탐색 애플리케이션의 예를 보려면 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();
                }
            }
        };
    }
    

참고: 위의 구현은 기능을 설명하기 위해 제시된 간단한 예입니다. 자체 앱에서 이 기능을 만드는 경우 성능을 높이기 위해 별도의 스레드에서 백그라운드 업데이트 작업을 실행하도록 고려하는 것이 좋습니다. 또한, 사용자가 항목을 스크롤하는 데 대한 반응으로 백그라운드를 업데이트하고자 한다면 사용자가 한 항목에 멈출 때까지 백그라운드 이미지 업데이트를 지연시키도록 시간을 추가하는 것을 고려해보세요. 이러한 방법을 사용하면 과도한 백그라운드 이미지 업데이트를 방지할 수 있습니다.