티스토리 뷰

1. 안드로이드 유튜브 앱 만들어보기

안드로이드 유튜브 앱을 비슷하게 만들어보면서 익혔던 내용들을 정리해보았습니다. ExoPlayer 로 동영상을 재생하는 방법과 모션 레이아웃을 통해서 재생중인 동영상 프레그먼트를 아래로 쓸어 내리면 유튜브와 비슷하게 하단에서 재생이되는 방법을 주로 다뤄 봤습니다.

주요 기능

  • 서버에서 동영상 목록 받아와서 보여주기
  • 메인에서 동영상 항목 클릭 시 하단에 내려져 있던 프레그먼트가 올라오면서 재생
  • 재생 도중 영상 부분을 쓸어 내리면 하단 배너형식으로 재생 및 일시정지 가능
  • 모션 레이아웃, 리사이클러뷰 사이 스크롤이 가능하도록 구현

사용 기술

  • MotionLayout
  • ExoPlayer
  • mocky

결과 화면

screenshot

1.1. 기본 레이아웃 구성

기본 메인 레이아웃은 위와 같이 구성되어있으며 하기 xml 코드를 보면 하단 메뉴(BottomNavigationView), 동영상 리스트를 보여줄 RecyclerView, 그리고 애니메이션을 줄 FrameLayout 으로 구성을 하였습니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout 
		...
    android:id="@+id/mainMotionLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/activity_main_scene"
    tools:context=".MainActivity">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/mainBottomNavigationView"
       ...
        app:menu="@menu/bottom_nav_menu" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/mainRecyclerView"
       ... />

    <!--
        애니메이션이 일어날 프레임 레이아웃
    -->
    <FrameLayout
        android:id="@+id/fragmentContainer"
        .../>


</androidx.constraintlayout.motion.widget.MotionLayout>

모션 레이아웃 변환

모션 레이아웃을 작성하는 방법으로는 초반에는 ConstraintLayout 기반으로 작성을 한 뒤에 Convert to MotionLayout 을 통해서 모션 레이아웃으로 변환해주면 되겠습니다.

1.2. BottomNavigationView 메뉴 붙이기

BottomNavigationView에 메뉴를 달아주기 위해서 아래와 같은 xml 자원을 하나 추가합니다. (menu 라는 패키지를 하나 추가해서 그 안에 넣어주었습니다.) 간단하게 홈화면 아이콘과 문자열 자원을 추가해서 메뉴의 아이템으로 지정해주었습니다.

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@+id/home"
        android:icon="@drawable/ic_baseline_home_24"
        android:title="@string/home"/>
</menu>

2. 동영상 재생 전용 Fragment 구현하기

Fragment 를 상속 받는 우리의 PlayerFragment 를 정의해줍니다. 내부에 추가되는 자세한 소스는 아래에서 하나씩 다뤄볼 예정입니다.

class PlayerFragment : Fragment(R.layout.fragment_player) {
	...
}

그 전에 우선 xml 파일을 추가해서 아래와 같이 디자인 해줍니다. 먼저 ConstraintLayout 으로 작성을 해줄건데 펼쳐져있는 상태를 기준으로 작성해주도록 합니다. (향후 모션레이아웃 전환 후 아래로 접었을 때 모션을 지정해줄 것임.)

fragment_player.xml

<?xml version="1.0" encoding="utf-8"?>
<com.lilcode.aop.p4c01.youtube.CustomMotionLayout 
	...
    android:id="@+id/playerMotionLayout"
    app:layoutDescription="@xml/fragment_player_scene">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/mainContainerLayout"
        android:layout_width="0dp"
        android:layout_height="250dp"
        android:background="#AAAAAA"
		... />



    <!--    동영상 플레이어-->
    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/playerView"
        ...
        app:resize_mode="fill"
        />

    <ImageView
        android:id="@+id/bottomPlayerControlButton"
        ...
        android:src="@drawable/ic_baseline_play_arrow_24"
        />


    <TextView
        android:id="@+id/bottomTitleTextView"
        ...
        android:ellipsize="end"
        android:maxLines="1"
        android:singleLine="true"
        ...
         />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/fragmentRecyclerView"
        ...
        android:clipToPadding="false"
        android:nestedScrollingEnabled="false"
        ... />
</com.lilcode.aop.p4c01.youtube.CustomMotionLayout>

동영상 플레이어와 하단으로 접었을 때 보여줄 재생/일시정지 버튼을 표시할 이미지뷰, 타이틀뷰 그리고 펼쳤을 때 보여줄 동영상 리스트를 위한 리사이클러뷰를 추가해주었습니다.

그리고 마찬가지로 모션 레이아웃으로 변환해주시면 됩니다. 모션 레이아웃으로 변환시 자동으로 _scene 이 붙은 xml 파일이 생성됩니다. 이후 해당 파일에 애니메이션에 대한 정보를 입력하게됩니다.

이때 android:clipToPadding="false" 를 주어 상하단 리사이클러 뷰 자체에 빈공백은 안생기도록 옵션추가

2.1. 모션 레이아웃 애니메이션 정의하기

모션 레이아웃을 정의했다면 Constraint 를 추가해서 애니메이션을 정의해줄 수 있습니다.

이때 start 에서 end 로 가는 애니메이션을 정의하게 되는데 해당 프로젝트에서는 start 를 접힌 상태, end를 펼친 상태로 생각하고 정의해주도록 했습니다.

start 와 end 에서의 Constraint 를 각각 추가 한뒤 값을 아래와 같이 설정해줍니다.

end -> start 방향 지정하기

모션 레이아웃 xml 에서 터치 아이콘 + 로 되어있는 버튼으로 click or swipe handler 를 추가할 수 있습니다.

start 에서 end 로 가는데 dragUp을 통해 갈 수 있도록 해주면 이제 아래에서 위로 드래그 하면 start -> end 가 되도록 명시가 되었습니다. 그리고 이제 모션 제약을 추가해서 어떻게 모션이 이루어질 지 추가해주겠습니다.

start

    <ConstraintSet android:id="@+id/start">
        <!--        layout_marginBottom 바텀 메뉴뷰 있기 때문에 위에서 좀 뗌-->
        <Constraint
            android:id="@+id/fragmentRecyclerView"
            android:layout_width="0dp"
            android:layout_height="0.1dp"
            android:layout_marginBottom="66dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintTop_toBottomOf="@id/mainContainerLayout"
            motion:layout_constraintVertical_bias="1.0" />
        <Constraint
            android:id="@+id/mainContainerLayout"
            android:layout_width="0dp"
            android:layout_height="56dp"
            android:layout_marginBottom="66dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintTop_toTopOf="parent"
            motion:layout_constraintVertical_bias="1.0" />
        <Constraint
            android:id="@+id/playerView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            motion:layout_constraintBottom_toBottomOf="@id/mainContainerLayout"
            motion:layout_constraintDimensionRatio="H, 1:2.5"
            motion:layout_constraintStart_toStartOf="@id/mainContainerLayout"
            motion:layout_constraintTop_toTopOf="@id/mainContainerLayout" />
    </ConstraintSet>

end

<ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@+id/playerView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            motion:layout_constraintEnd_toEndOf="@id/mainContainerLayout"
            motion:layout_constraintBottom_toBottomOf="@id/mainContainerLayout"
            motion:layout_constraintTop_toTopOf="@id/mainContainerLayout"
            motion:layout_constraintStart_toStartOf="@id/mainContainerLayout" />
        <Constraint
            android:id="@+id/mainContainerLayout"
            motion:layout_constraintEnd_toEndOf="parent"
            android:layout_width="0dp"
            android:layout_height="250dp"
            motion:layout_constraintTop_toTopOf="parent"
            motion:layout_constraintStart_toStartOf="parent" />
        <Constraint
            android:id="@+id/fragmentRecyclerView"
            motion:layout_constraintEnd_toEndOf="parent"
            android:layout_width="0dp"
            android:layout_height="0dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintTop_toBottomOf="@id/mainContainerLayout"
            motion:layout_constraintStart_toStartOf="parent" />
        <Constraint
            android:id="@+id/bottomPlayerControlButton"
            motion:layout_constraintEnd_toEndOf="@id/mainContainerLayout"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            motion:layout_constraintBottom_toBottomOf="@id/mainContainerLayout"
            android:alpha="0"
            android:layout_marginEnd="24dp"
            motion:layout_constraintTop_toTopOf="@id/mainContainerLayout" />
    </ConstraintSet>

이후 start -> end 로 가면서 설정해 준 제약을 바탕으로 모션이 일어나게 됩니다.

start(아래로 접은) 상태에서 동영상 목록을 나타내는 리사이클러뷰는 0.1dp 로 거의 보이지 않도록 설정하였고 메인 컨테이너의 높이를 56dp 로 줄여 하단에 붙여줍니다. 하단으로 붙여주는 속성은 아래와 같습니다.

motion:layout_constraintVertical_bias="1.0"

이런 식으로 접었을 때의 모습과 펼쳤을 때의 모습을 설정해줄 수 있습니다.

2.2. Transition 주기

유튜브 앱을 참고하면 현재 글 쓰는 시점 기준으로 모션레이아웃을 아래로 내리거나 위로 올릴 때 동영상 플레이어 뷰의 크기가 점점 커지는 것이 아니라 하단 부분에서 너비를 100%로 먼저 채워주면서 모션이 일어나는 것을 알 수 있습니다.

이러한 세부 사항은 Transition 을 만지면 적용해줄 수 있습니다. 0~100 에 해당하는 위치에서 원하는 위치에 KeyPosition, KeyAttribute 를 추가해서 설정해줄 수 있습니다.

아래 코드는 0에서 10이 되었을 때 타이틀 택스트 뷰와 컨트롤 버튼의 투명도가 100%가 되어 보이지 않도록 하고 플레이어 뷰의 경우에는 너비를 다 채워줘서 10 위치에서는 너비가 꽉 찰 수 있도록 구현한 코드입니다.

  • curveFit 으로 linear 를 주어 곡선이 아닌 선형으로 진행되게 해줄 수 있습니다.
  • percentX 를 1로 주어 플레이어 뷰를 중앙으로 배치시킵니다.
  • duration 값을 낮춰 보다 빠른 애니메이션이 동작하도록 설정할 수 있습니다.
    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="300">
        <KeyFrameSet>
            <KeyAttribute
                motion:motionTarget="@+id/bottomTitleTextView"
                motion:framePosition="10"
                android:alpha="0.0" />
            <KeyAttribute
                motion:motionTarget="@+id/bottomPlayerControlButton"
                motion:framePosition="10"
                android:alpha="0.0" />
            <KeyPosition
                motion:motionTarget="@+id/playerView"
                motion:framePosition="10"
                motion:keyPositionType="deltaRelative"
                motion:curveFit="linear"
                motion:percentWidth="1"
                motion:percentX="1" />
        </KeyFrameSet>
        <OnSwipe
            motion:touchAnchorId="@+id/mainContainerLayout"
            motion:touchAnchorSide="bottom" />
    </Transition>

실행 동영상 화면

먼저 최종 실행 화면 영상을 보도록 하겠습니다. (그래야 어느정도 하는 맛이 있으니..)

2.3. Main MotionLayout 과 Fragment MotionLayout 연동해주기

위에서 언급하지 않았지만, 메인 화면에서의 하단 메뉴(홈 메뉴가 있는 메뉴) 또한 모션 레이아웃으로 영상이 펼쳐질 때 숨겨지도록 구현해두었습니다. 해당 모션이 정상 작동하도록 해주기 위해서는 둘을 연결해주는 코드 작업이 필요합니다.

뷰 바인딩을 사용하여 PlayerFragment 클래스에 아래 코드를 추가합니다.

class PlayerFragment : Fragment(R.layout.fragment_player) {

    private var binding: FragmentPlayerBinding? = null
    private lateinit var mainBinding: ActivityMainBinding

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

        val fragmentPlayerBinding = FragmentPlayerBinding.bind(view)
        mainBinding = ActivityMainBinding.inflate(layoutInflater)
        binding = fragmentPlayerBinding

        fragmentPlayerBinding.playerMotionLayout.setTransitionListener(object :
            MotionLayout.TransitionListener {
            override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) {
            }

            override fun onTransitionChange(
                motionLayout: MotionLayout?,
                startId: Int,
                endId: Int,
                progress: Float
            ) {
                /**
                 * 메인 엑티비티 모션 레이아웃에 값을 전달
                 */
                binding?.let {
                    /**
                     * Fragment 는 자기 단독으로 존재할 수 없기 떄문에 activity 가 존재 할수밖에 없고
                     * activity 를 가져오면 해당 Fragment 가 attach 되어있는 액티비티를 가져온다.
                     */
                    (activity as MainActivity).also { mainActivity ->
                        mainActivity.findViewById<MotionLayout>(mainBinding.mainMotionLayout.id).progress =
                            abs(progress)
                    }
                }
            }

            override fun onTransitionCompleted(p0: MotionLayout?, p1: Int) {
            }

            override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
            }

        })

    }

    override fun onDestroy() {
        super.onDestroy()

        binding = null
    }

PlayerFragment의 최상위 뷰인 MotionLayout에 TransitionListener를 설정합니다.

onTransitionChange를 재정의 하여 트랜지션이 바뀔 때 메인 엑티비티의 모션 레이아웃의 progress 도 PlayerFragment의 progress와 동일하게 연동해줍니다.

참고로 onDestroy() 시 binding에 null 을 대입해주었는데, 이는 뷰애 비해 프래그먼트의 수명이 길기 때문에 (바인딩 클래스는 뷰에 대한 참조를 가지고 있으므로) 뷰가 제거될 때 호출되는 onDestory()에서 바인딩 클래스의 인스턴스를 정리해주었습니다.

3. retrofit 으로 Mock json 데이터 받아오기

retrofit의 경우 이전 프로젝트 글 들에서 다룬적이 있기 때문에 자세한 설명은 생략하고 코드로 대체합니다.

[/model/VideoModel.kt]

data class VideoModel(
    val title: String,
    val sources: String,
    val subtitle: String,
    val thumb:String,
    val description: String
)

[/dto/VideoDto.kt]

data class VideoDto (
    val videos : List<VideoModel>
)

[/service/VideoService.kt]

import com.lilcode.aop.p4c01.youtube.dto.VideoDto
import retrofit2.Call
import retrofit2.http.GET


interface VideoService {

    @GET("/v3/66f12ec7-a2e6-4070-b7cc-7f3563fbe962")
    fun listVideos(): Call<VideoDto>
}

[/youtube/MainActivity.kt]

...
    private fun getVideoList(){
        val retrofit = Retrofit.Builder()
            .baseUrl("https://run.mocky.io/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()

        retrofit.create(VideoService::class.java).also{
            it.listVideos()
                .enqueue(object: Callback<VideoDto>{
                    override fun onResponse(call: Call<VideoDto>, response: Response<VideoDto>) {
                        if(response.isSuccessful.not()){
                            Log.e("MainActivity","response fail")
                            return
                        }

                        response.body()?.let{
                            Log.d("MainActiviy", it.toString())
                        }
                    }

                    override fun onFailure(call: Call<VideoDto>, t: Throwable) {
                        // 예외처리
                    }

                })
        }
...

3.1. 비디오 리사이클러뷰 어뎁터 및  아이템 레이아웃

[/adapter/VideoAdapter.kt]

class VideoAdapter : ListAdapter<VideoModel, VideoAdapter.ViewHolder>(diffUtil) {

    inner class ViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
        fun bind(item: VideoModel) {
            with(ItemVideoBinding.bind(view)){
                titleTextView.text = item.title
                subTitleTextView.text = item.subtitle

                Glide.with(thumbnailImageView.context)
                    .load(item.thumb)
                    .into(thumbnailImageView)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(
            LayoutInflater.from(parent.context).inflate(R.layout.item_video, parent, false)
        )
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        return holder.bind(currentList[position])
    }

    companion object {
        val diffUtil = object : DiffUtil.ItemCallback<VideoModel>() {
            override fun areItemsTheSame(oldItem: VideoModel, newItem: VideoModel): Boolean {
                return oldItem == newItem // future: id
            }

            override fun areContentsTheSame(oldItem: VideoModel, newItem: VideoModel): Boolean {
                return oldItem == newItem
            }

        }
    }


}

[/layout/item_video.xml]

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
	...
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/thumbnailImageView"
        android:layout_width="0dp"
        android:layout_height="230dp"
        android:scaleType="centerCrop"
        ... />

    <TextView
        android:id="@+id/titleTextView"
        ... />

    <TextView
        android:id="@+id/subTitleTextView"
        ...
        android:maxLines="1"
        android:singleLine="true"
        android:ellipsize="end"
         />

    <ImageView
        android:id="@+id/logoImageView"
        ... />

</androidx.constraintlayout.widget.ConstraintLayout>

이후 메인에서 retrofit으로 받아온 dto 클래스의 비디오 목록을 해당 어뎁터의 리스트로 등록해서 사용하면 되겠습니다.

3.2. 터치 이벤트 정상 동작 시키기

위에서 본것과 같이 플레이어 프레그먼트에서 위로 드래그 하거나 아래로 드래그래서 열거나 펼칠 수 있습니다. 이때 열린 상태에서 동영상뷰 아래에 있는 동영상 리스트 탐색을 위해 위아래로 터치해보면 리스트가 탐색되지 않고 접히는 모션으로 오동작하게됩니다.

그래서 따로 커스터마이징하여 동영상 뷰를 포함한 영역에서 터치한 경우에만 모션이 발생하도록 바로 잡아 주어야 합니다.

따로 motionTouchStarted 를 추가하여 해당 플래그가 true인 경우에만 모션이 이루어지도록 해주었습니다. (코드 보러가기)

이후 추가로 리사이클러뷰에 android:nestedScrollingEnabled="false" 속성을 주어 스크롤이 유저가 느끼기에 정상적으로 동작하도록 해주었습니다.

4. ExoPlayer 사용하기

ExoPlayer 는 구글에서 안드로이드 SDK 와 별도로 배포하고 있는 오픈소스 프로젝트로 오디오 및 동영상을 재생할 수 있고 이에 더해 강력한 기능들이 포함되어있는 라이브러리입니다. 실제로 유튜브에서 ExoPlayer를 사용하고 있다고합니다.

사용 방법은 아래와 같습니다. 프래그먼트에서 사용하는 코드의 일부를 가져왔고 context 를 통해 빌드를 해주면 됩니다.

    private fun initPlayer() {
        context?.let{
            player = SimpleExoPlayer.Builder(it).build()
        }

        binding!!.playerView.player = player
    }

이후 동영상 url을 받아서 play 시켜주는 코드는 아래와 같습니다.

    // 동영상 아이템 눌렀을 때 처리
    fun play(url: String, title: String) {

        context?.let{
            val dataSourceFactory = DefaultDataSourceFactory(it)
            val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
                .createMediaSource(MediaItem.fromUri(Uri.parse(url)))
            player?.setMediaSource(mediaSource)
            player?.prepare()
            player?.play()
        }
		...
}

모션 레이아웃 start -> end 전환 코드

메인 리스트에서 동영상을 골라서 클릭하여 재생하는 경우에는 플레이어가 포함된 프레그먼트 뷰가 펼쳐지면서(end) 동영상이 재생되도록 하기위해서 아래와 같은 코드 또한 추가해줍니다. 모션 레이아웃의 transition을 end로 만들어 열어주는 방식입니다.

        binding?.let {
            it.playerMotionLayout.transitionToEnd() // 열기
            it.bottomTitleTextView.text = title
        }

4.1. Fragment 의 생명주기에 따른 재생 컨트롤

재생을 하던 도중 스마트폰의 홈키를 눌러서 홈 화면으로 나가는 경우에도 계속 재생이 될텐데 이는 사실 우리가 유도한 기능이 아닙니다. 때문에 해당 프로젝트에서는 홈화면으로 나가는 등의 화면 전환이 이루어 진다면 영상 재생을 일시정지하도록 했습니다.

onDestory() 시에는 release()를 통해 메모리를 해제 해줍니다.

    override fun onStop() {
        super.onStop()

        player?.pause()
    }

    override fun onDestroy() {
        super.onDestroy()

        binding = null
        player?.release()
    }

프로젝트 전체 코드에 대해서는 저의 깃허브 저장소를 방문하시면 확인할 수 있습니다.

2021.07.11 - [Android] - [Android] 화면 돌려도(회전시) 데이터 유지 시키기 : onSaveInstanceState

2021.07.06 - [Android/Kotlin] - [Android] dp를 px로 변환해주는 융통성 있는 코드 (dp to pixel)

2021.06.30 - [Android/App] - [Android] 에어비앤비 앱 만들어 보기 (네이버 지도 api, mocky 등)

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함