완성 화면
주요 기능
사용 기술
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/mainBottomNavigationView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_navi_menu" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/mainRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
메인 액티비티에는 하단 메뉴인 BottomNavigationView, 동영상 리스트를 보여줄 RecycerView, 스크롤 가능한 애니메이션을 주기 위해 프래그먼트를 붙일 수 있는 FrameLayout 으로 구성하였다.
fragment_player.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mainContainerLayout"
android:layout_width="0dp"
android:layout_height="250dp"
android:background="@color/purple_200"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/playerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@id/mainContainerLayout"
app:layout_constraintStart_toStartOf="@id/mainContainerLayout"
app:layout_constraintTop_toTopOf="@id/mainContainerLayout" />
<ImageView
android:id="@+id/bottomPlayerControlButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
app:layout_constraintBottom_toBottomOf="@id/mainContainerLayout"
app:layout_constraintEnd_toEndOf="@id/mainContainerLayout"
app:layout_constraintTop_toTopOf="@id/mainContainerLayout" />
<TextView
android:id="@+id/bottomTitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
app:layout_constraintBottom_toBottomOf="@id/bottomPlayerControlButton"
app:layout_constraintEnd_toStartOf="@id/bottomPlayerControlButton"
app:layout_constraintStart_toEndOf="@id/playerView"
app:layout_constraintTop_toTopOf="@id/bottomPlayerControlButton" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/fragmentRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/mainContainerLayout" />
</androidx.constraintlayout.widget.Constraintlayout>
동영상 플레이어와 하단으로 접었을 때 보여줄 재생/일시정지 버튼을 표시할 ImageView, 제목을 표시할 TextView 그리고 펼쳤을 때 보여줄 동영상 리스트를 위한 recyclerView를 추가해주었다.
Design > Contraint > Convert to MotionLayout > Convert
모션 레이아웃으로 변환해주면 자동으로 xml 폴더가 생성되고 fragment_player_scene.xml 파일이 생성된다. 이후 해당 파일에 애니메이션에 대한 정보를 입력해준다.
모션 레이아웃을 정의했다면 Contraint를 추가해 start에서 end로 가는 애니메이션을 정의하게 되는데 해당 프로젝트에서는 start를 접힌 상태, end를 펼친 상태로 생각해주고 정의하였다.
start와 end 에서의 create Contraint를 통해 각각 속성을 추가한 뒤 값을 설정해준다.
xml/fragment_player_scene.xml
<ConstraintSet android:id="@+id/start">
<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>
<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" />
</ConstraintSet>
접힌 상태에서 Drag Up 을 해주면 화면이 펼쳐지게끔 하기 위해 swipe handler를 눌러 start -> end 방향을 지정해주고 어떻게 모션이 이루어질지 제약을 추가해주었다.
하단메뉴 모션
activity_main.scene.xml
<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@+id/mainBottomNavigationView"
android:layout_width="0dp"
android:layout_height="wrap_content"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent" />
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@+id/mainBottomNavigationView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:translationY="56dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent" />
</ConstraintSet>
Main MotionLayout 과 Fragment MotionLayout 연동
MainActivity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, PlayerFragment())
.commit()
}
}
FrameLayout에 Fragment를 attach 해주기 위해 supportFragmentManager를 열어 붙여주었다.
메인 화면에서의 bottomNavigationView(하단 홈메뉴) 걸어두었던 무션을 정상적으로 작동시키기 위해서는 둘을 연결해주는 작업이 필요하다.
PlayerFragment
import android.os.Bundle
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.fragment.app.Fragment
import com.example.youtube.databinding.FragmentPlayerBinding
import kotlin.math.abs
class PlayerFragment : Fragment(R.layout.fragment_player) {
private var binding: FragmentPlayerBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentPlayerBinding = FragmentPlayerBinding.bind(view)
binding = fragmentPlayerBinding
initMotionLayoutEvent(fragmentPlayerBinding)
}
private fun initMotionLayoutEvent(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.playerMotionLayout.setTransitionListener(object : MotionLayout.TransitionListener {
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int) {}
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
binding?.let {
(activity as MainActivity).also { mainActivity ->
// mainActivity 로 치환해서 형변환
mainActivity.findViewById<MotionLayout>(R.id.mainMotionLayout).progress = abs(progress)
}
}
}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {}
override fun onTransitionTrigger(motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float) {}
})
}
override fun onDestroy() {
super.onDestroy()
binding = null // onDestroy 될 때 binding 해제
}
}
PlayerFragment 의 최상단 뷰인 MotionLayout에 TransitionListener를 설정해준다. onTransitionChange를 재정의하여 트랜지션이 바뀔 때 메인 액티비티의 MotionLayout의 progress도 PlayerFragment의 progress와 동일하게 연동해준다.
프래그먼트는 단독으로 존재할 수 없기 때문에 액티비티가 존재할 수밖에 없다. 따라서 액티비티를 가져오면 해당 프래그먼트가 attach 되어있는 액티비티를 가져온다.
뷰에 비해 프래그먼트 수명이 길기 때문에 뷰가 제거될 때 호출되는 onDestroy에서 바인딩 클래스의 인스턴스를 정리해주었다.
recyclerView 에 채워줄 영상을 받아올 수 있는 api를 mocky 서비스를 이용해 서버로부터 영상 목록을 받아온다.
https://gist.github.com/deepakpk009/99fd994da714996b296f11c3c371d5ee
에서 api를 받아와 조금 수정해주었다.
VideoModel
data class VideoModel(
val title: String,
val sources: String,
val subtitle: String,
val thumb: String,
val description: String
)
VideoService
import com.example.youtube.dto.VideoDto
import retrofit2.Call
import retrofit2.http.GET
interface VideoService {
@GET("/v3/cbb293eb-e8b2-4079-ba82-472d1c0419d1")
fun listVideos(): Call<VideoDto>
}
만든 api를 토대로 model 만들고, VideoModel을 가진 video 리스트 형식의 array 전체가 dto로 담기게 해준다. retrofit을 이용해서 메인 액티비티에서 제대로 api를 불러오는지 확인하기 위해 VideoService 인터페이스로 만들어 준다.
MainActivity
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.d("MainActivity", "response fail")
return
}
response.body()?.let { videoDto ->
Log.d("MainActivity", videoDto.toString())
}
}
override fun onFailure(call: Call<VideoDto>, t: Throwable) {
// 예외처리
}
})
}
}
메인 액티비티에 retrofit 구현체를 만들고 VideoService 인터페이스를 enqueue 해서 dto를 가져온다. 로그캣으로 데이터가 잘 받아와지는지 확인해주었다.
레트로핏 사용하기 위해서는 인터넷 퍼미션 추가해주는 것 잊지말기!!
response.body()?.let { } 에서 람다를 it으로 해주고 로그캣을 찍으면 데이터가 안 받아와지고 response fail이 떠서 람다에 videoDto으로 해주고 로그캣을 찍으니까 잘 받아와졌다.
가져온 데이터를 메인 액티비티와 프래그먼트에 있는 recyclerView에 뿌려주기 위해 어댑터를 생성했다.
VideoAdapter
import com.example.youtube.model.VideoModel
class VideoAdapter(val callback: (String, String) -> Unit) : ListAdapter<VideoModel, VideoAdapter.ViewHolder>(diffUtil) {
inner class ViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind(item: VideoModel) {
val titleTextView = view.findViewById<TextView>(R.id.titleTextView)
val subTitleTextView = view.findViewById<TextView>(R.id.subTitleTextView)
val thumbnailImageView = view.findViewById<ImageView>(R.id.thumbnailImageView)
titleTextView.text = item.title
subTitleTextView.text = item.subtitle
Glide.with(thumbnailImageView.context)
.load(item.thumb)
.into(thumbnailImageView)
view.setOnClickListener{
callback(item.sources, item.title)
}
}
}
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
}
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 xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
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"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/titleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/logoImageView"
app:layout_constraintTop_toTopOf="@id/logoImageView"
tools:text="유튜브 제목" />
<TextView
android:id="@+id/subTitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:textSize="13sp"
app:layout_constraintEnd_toEndOf="@id/titleTextView"
app:layout_constraintStart_toStartOf="@id/titleTextView"
app:layout_constraintTop_toBottomOf="@id/titleTextView"
tools:text="유튜브 소제목" />
<ImageView
android:id="@+id/logoImageView"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="16dp"
android:src="@drawable/shape_circle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thumbnailImageView" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var videoAdapter: VideoAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, PlayerFragment())
.commit()
videoAdapter = VideoAdapter()
findViewById<RecyclerView>(R.id.mainRecyclerView).apply {
adapter = videoAdapter
layoutManager = LinearLayoutManager(context)
}
getVideoList()
}
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.d("MainActivity", "response fail")
return
}
response.body()?.let { videoDto ->
videoAdapter.submitList(videoDto.videos)
}
}
VideoAdapter 를 메인 액티비티에 연결시켜 서버에서 가져온 영상 목록이 뷰에 보여지도록 하였다.
프레임레이아웃에 붙어있는 프래그먼트에 있는 MotionLayout이 터치를 다 가져가버려서 뒤에 있는 recyclerView가 터치되지 않는 문제점이 있다. 따라서 커스터마이징하여 mainContainerView 영역에 터치한 경우에만 motionLayout이 정상적으로 터치가 동작하도록 하고, 그 외 영역을 터치한 경우 터치이벤트를 무시하게 만들어줬다.
CustomMotionLayout
class CustomMotionLayout(context: Context, attributeSet: AttributeSet? = null) : MotionLayout(context, attributeSet) {
private var motionTouchStarted = false
private val mainContainerView by lazy {
findViewById<View>(R.id.mainContainerLayout)
}
private val hitRect = Rect()
init {
setTransitionListener(object : TransitionListener {
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int) {}
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
motionTouchStarted = false
}
override fun onTransitionTrigger(motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float) {}
})
}
override fun onTouchEvent(event: MotionEvent): Boolean {
// ACTION_UP 과 ACTION_CANCEL 은 필요 없어서 when 문으로 motionTouchStarted 값을 false로 주었다.
when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
motionTouchStarted = false
return super.onTouchEvent(event)
}
}
if (!motionTouchStarted) {
mainContainerView.getHitRect(hitRect)
motionTouchStarted = hitRect.contains(event.x.toInt(), event.y.toInt())
}
return super.onTouchEvent(event) && motionTouchStarted
}
private val gestureListener by lazy {
object : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
mainContainerView.getHitRect(hitRect)
return hitRect.contains(e1.x.toInt(), e1.y.toInt())
}
}
}
private val gestureDetector by lazy {
GestureDetector(context, gestureListener)
}
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
return gestureDetector.onTouchEvent(event)
}
}
커스터마이징 해주고 fragment_player.xml 모션레이아웃 속성을 커스터마이징한 모션레이아웃으로 바꿔준다.
fragment_player.xml
android:nestedScrollingEnabled="false"
android:padding="16dp"
android:clipToPadding="false"
recyclerView에 nestedScrollingEnabled 속성을 false 로 주어 스크롤이 사용자가 느끼기에 정상적으로 동작하도록 해주었고, 꽉 찬 동영상 화면을 패딩을 주어 사이즈를 줄여주고 제일 윗 영역에만 패딩을 주고 아래 영역에 패딩값을 주지 않게 하기 위해 clipToPadding 속성을 주었다.
VideoAdapter
class VideoAdapter(val callback: (String, String) -> Unit) : ListAdapter<VideoModel, VideoAdapter.ViewHolder>(diffUtil) {
inner class ViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind(item: VideoModel) {
val titleTextView = view.findViewById<TextView>(R.id.titleTextView)
val subTitleTextView = view.findViewById<TextView>(R.id.subTitleTextView)
val thumbnailImageView = view.findViewById<ImageView>(R.id.thumbnailImageView)
titleTextView.text = item.title
subTitleTextView.text = item.subtitle
Glide.with(thumbnailImageView.context)
.load(item.thumb)
.into(thumbnailImageView)
view.setOnClickListener {
callback(item.sources, item.title)
}
}
}
비디오를 눌렀을 때 프래그먼트에 이벤트를 전달하도록 하기 위해서 VideoAdapter 에 클릭 리스너를 추가해주고, 클릭 리스너가 바깥으로 나갈 수 있도록 바깥에서 callback 이라는 이름으로 람다를 받아온다.
MainActivity
videoAdapter = VideoAdapter(callback = { url, title ->
supportFragmentManager.fragments.find {it is PlayerFragment}?.let {
(it as PlayerFragment).play(url, title)
}
})
PlayerFragment
private fun initRecyclerView(fragmentPlayerBinding: FragmentPlayerBinding) {
videoAdapter = VideoAdapter(callback = { url, title ->
play(url, title)
})
fragmentPlayerBinding.fragmentRecyclerView.apply {
adapter = videoAdapter
layoutManager = LinearLayoutManager(context)
}
}
fun play(url: String, title: String) {
binding?.let {
it.playerMotionLayout.transitionToEnd()
it.bottomTitleTextView.text = title
}
}
메인 액티비티와 PlayerFragment의 videoAdapter를 초기화해주는 부분에서 callback 함수를 정의해주고 url과 title이 넘어왔을 때 프래그먼트에 url과 title을 업데이트 해주도록 구현하였다.
ExoPlayer
는 구글이 안드로이드 SDK와는 별도로 배포되는 오픈소스 프로젝트로 오디오 및 동영상 재생, 재생 관련 기능들을 포함하고 있는 라이브러리이다. 다른 설정 없이도 네트워크로부터 미디어를 스트리밍 형태로 불러와 재생할 수도 있고 다양한 포맷들을 지원하며, 커스터마이징도 지원한다. ExoPlayer를 사용한 대표적인 서비스로는 유튜브가 있다.
앱수준 build.gradle
implementation 'com.google.android.exoplayer:exoplayer:2.16.'
fragment_player.xml
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:resize_mode="fill"
app:layout_constraintBottom_toBottomOf="@id/mainContainerLayout"
app:layout_constraintStart_toStartOf="@id/mainContainerLayout"
app:layout_constraintTop_toTopOf="@id/mainContainerLayout" />
임시로 ImageView 로 설정해둔 동영상 영역이 될 뷰를 PlayerView 로 변경해주고 각각 다른 동영상 사이즈를 resize_mode="fill" 속성을 통해 맞춰주었다.
PlayerFragment
private var player: SimpleExoPlayer? = null
private fun initPlayer(fragmentPlayerBinding: FragmentPlayerBinding) {
context?.let {
// SimpleExoPlayer 인스턴스
player = SimpleExoPlayer.Builder(it).build()
}
fragmentPlayerBinding.playerView.player = player
}
fun play(url: String, title: String) {
context?.let {
val dataSourceFactory = DefaultDataSourceFactory(it)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
// MediaItem 을 만들고
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
// MediaSource 를 연결
player?.setMediaSource(mediaSource)
// player 준비
player?.prepare()
// player 재생
player?.play()
binding?.let {
it.playerMotionLayout.transitionToEnd()
it.bottomTitleTextView.text = title
}
}
영상 리스트에서 영상을 클릭하여 재생할 때 플레이어가 포함된 프래그먼트 뷰가 펼쳐지면서 영상이 재생되도록 구현했다.
PlayerFragment
private fun initPlayer(fragmentPlayerBinding: FragmentPlayerBinding) {
context?.let {
player = SimpleExoPlayer.Builder(it).build()
}
fragmentPlayerBinding.playerView.player = player
binding?.let {
player?.addListener(object : Player.EventListener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
if (isPlaying) {
it.bottomPlayerControlButton.setImageResource(R.drawable.ic_baseline_pause_24)
} else {
it.bottomPlayerControlButton.setImageResource(R.drawable.ic_baseline_play_arrow_24)
}
}
})
}
}
private fun initControlButton(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.bottomPlayerControlButton.setOnClickListener {
val player = this.player ?: return@setOnClickListener
if (player.isPlaying) {
player.pause()
} else {
player.play()
}
}
}
플레이 버튼을 눌렀을 때 영상이 재생됨과 동시에 일시정지 버튼이 뜨고, 일시정지 버튼을 눌렀을 때 영상이 멈춤과 동시에 플레이 버튼이 뜨도록 코드를 작성하였다.
하지만 실행해보면 모션레이아웃을 아래로 내리거나 위로 올릴 때 영상 플레이어 뷰의 크기가 점점 커지는 것이 아니라 하단 부분에서 너비를 꽉 차게 채워주면서 모션이 일어나고, 플레이어 버튼이 엉뚱한 곳에 위치한 것을 볼 수 있다.
이를 해결해주기 위해 모션 레이아웃을 추가해준다.
xml/fragment_player_scene.xml
<KeyFrameSet>
<KeyAttribute
android:alpha="0"
motion:framePosition="10"
motion:motionTarget="@+id/bottomTitleTextView" />
<KeyAttribute
android:alpha="0"
motion:framePosition="10"
motion:motionTarget="@+id/bottomPlayerControlButton" />
<KeyPosition
motion:curveFit="linear" // 곡선이 아닌 선형으로 진행될 수 있도록
motion:framePosition="0"
motion:keyPositionType="deltaRelative"
motion:motionTarget="@+id/playerView"
motion:percentWidth="1"
// 값을 1로 주어 플레이어 뷰를 중앙으로 배치
motion:percentX="1" />
</KeyFrameSet>
0에서 10%가 됐을 때 titleTextView 와 controlButton 의 투명도가 100%가 되어 보이지 않도록 하고, playerView 의 경우 너비를 다 채워 10% 위치에서는 너비가 꽉 찰 수 있도록 하였다.
xml/fragment_player_scene.xml
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@+id/bottomPlayerControlButton"
android:alpha="0"/>
</ConstraintSet>
fragment_player.xml
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mainContainerLayout"
<!--background="@color/purple_200" 지우고 -->
android:background="#AAAAAA" />
<TextView
android:textColor="@color/black" />
override fun onStop() {
super.onStop()
player?.pause()
}
override fun onDestroy() {
super.onDestroy()
binding = null
player?.release()
}
홈 화면으로 나가는 등의 화면 전환이 이루어질 때 영상 재생을 일시정지 하도록 설정해주기 위해 onDestroy() 시 release 를 통해 메모리를 해제 시켜주고 onStop() 시 pause 해주었다.