나의 귀여운 미니미 유튜브를 완성시켰다. 어찌저찌 나름의 기능은 비슷하다고 생각한다. 로컬에 동영상을 가지고 있는게 아닌 서버에서 동영상을 가져와서 보여주는 형태이다. 사실 그 작업 자체는 어렵지 않지만, 이번 강의에서 하단 프래그먼트 스와이프를 동작하는게 꽤나 어려웠다. 역시 프론트든 클라이언트든, 이런 디자인적인요소가 어렵구나 느낀다..!
# 동영상 Mock API 만들기
동영상 타이틀, 서브타이틀, 썸네일, mp4파일과 같이 API호출을 통해서 받아와야 한다. 내가 백엔드까지 짤 수 는 없기 때문에 당연히 더미 데이터를 이용한다. 저번에 사용해봤던 mocky를 사용하면 쉽게 만들 수 있다.
{
"videos": [
{
"description": "Big Buck Bunny tells the story of a giant rabbit with a heart bigger than himself. When one sunny day three rodents rudely harass him, something snaps... and the rabbit ain't no bunny anymore! In the typical cartoon tradition he prepares the nasty rodents a comical revenge.\n\nLicensed under the Creative Commons Attribution license\nhttps://www.bigbuckbunny.org",
"sources":
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
,
"subtitle": "By Blender Foundation",
"thumb": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg",
"title": "Big Buck Bunny"
},{
// ...
},
}
난 위 소스를 토대로 mock api를 만들어 줬다.
소스는 위처럼 받아오기 때문에 해당 타입에 맞춰 DTO와 model을 만들어 주면 된다.
# 서버 통신을 위한 데이터 구성하기
data class VideoDto(
@SerializedName("videos") val videos : List<VideoData>
)
data class VideoData (
val title : String,
val subtitle : String,
val description : String,
val thumb : String,
val sources : String,
)
# 리사이클러뷰 어댑터 및 레트로핏 서비스로 GET 호출
받아온 API를 뿌려주기 위해 리사이클러뷰를 만들어주고 어댑터를 만들어 준다. 리사이클러뷰 어댑터는 너무 많이 만들었기 때문에 생략하겠다.
이제 레트로핏으로 데이터를 받아오는 함수를 만들어 준다. 로그를 찍어서 데이터가 잘오는지도 한번 확인해주고, submitList로 리사이클러뷰에 데이터를 넣어준다.
private fun getVideos() {
val retrofit = Retrofit.Builder()
.baseUrl("https://run.mocky.io/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(RetrofitService::class.java)
service.getVideos().enqueue(object : Callback<VideoDto>{
override fun onResponse(call: Call<VideoDto>, response: Response<VideoDto>) {
if(response.isSuccessful.not()){
Toast.makeText(this@MainActivity,"response fail",Toast.LENGTH_SHORT).show()
}
val data = response.body()
data?.let { videoDto ->
videoAdapter.submitList(videoDto.videos)
videoDto.videos.forEach { video ->
Log.d("Retrofit",video.toString())
}
}
}
override fun onFailure(call: Call<VideoDto>, t: Throwable) {
Toast.makeText(this@MainActivity,"onFailure data",Toast.LENGTH_SHORT).show()
}
})
}
# 모션레이아웃과 리사이클러뷰 터치 이벤트 분리
한가지 문제점이 발생했는데, 동영상 리사이클러뷰를 밑으로 내려도 프레임 레이아웃부분에 터치가 반응하여 모션레이아웃이 반응해버리는 문제점이 생겼다. 구글을 조금 찾아 봤는데 뷰그룹간에 터치할 시에 계층 순서별로 onTouchEvnet가 true/false를 반환하는데 true로 반환해버리면 터치 이벤트를 처리했다는 뜻이기 때문에 그 밑에 뷰로는 터치가 왔다는 사실은 모른체 터치 반응이 끝나버리기 때문에 생기는 이슈였다.
모션레이아웃을 커스텀하기 위해 모션레이아웃을 상속받는 커스텀 모션레이아웃을 만들어주어 위 문제를 해결하도록 하자.
사실 완벽히 이해가 되지는 않았지만, 이런게 있다는 것을 알고 나중에 필요할때 자세히 공부할 필요가 있을거 같다.
# CustomMotionLayout.kt
구현하는 로직은 지금 터치한 곳이 mainContainer인지 아닌지를 알아내는 것이다.
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 {
Log.d("MotionLayout","onTouchEvent is called!!")
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())
Log.d("MotionLayout","motionTouchStarted : $motionTouchStarted")
}
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)
}
# 리사이클러뷰 클릭 리스너 추가하기
리사이클러뷰의 요소를 클릭하면 해당 동영상이 재생되고, 프레임레이아웃에 현재 재생중인 영상의 타이틀로 변경하는 작업을 해야한다. 영상을 띄우는 player는 일단 함수로 만들어 놓고, 클릭 리스너를 먼저 만들어 주도록 한다.
어댑터에 클릭 리스너를 달아준다. 타이틀과 데이터 소스를 인자로 받아줘야지, 콜백함수에서 해당 동영상을 플레이 시킬 수 있을 것이다.
class VideoAdpater(val mClickListener : (String,String)-> Unit) : ListAdapter<VideoData,VideoAdpater.ViewHolder>(diffUtill) {
inner class ViewHolder(private val view : View) : RecyclerView.ViewHolder(view){
val title = view.findViewById<TextView>(R.id.titleText)
val subTitle = view.findViewById<TextView>(R.id.subTitleText)
val thumbImg = view.findViewById<ImageView>(R.id.thumbnailImage)
fun bind(data : VideoData) {
title.text = data.title
subTitle.text = data.subtitle
view.setOnClickListener {
mClickListener(data.title, data.sources)
}
// 썸네일 이미지 바인딩
Glide.with(thumbImg.context)
.load(data.thumb)
.transform(CenterCrop())
.into(thumbImg)
}
}
videoAdpater = VideoAdpater(mClickListener = { title,url ->
playVideo(title,url)
})
구현한 앱에서는 리사이클러뷰가 두개이다. 메인화면에서 리사이클러뷰와, 동영상을 클릭하여 재생중인 창에서 나오는 리사이클러뷰로 말이다.
영상이 재생중인 두번째 화면에서 playPlayer()함수를 구현했다. 그리고 어댑터에서 구현한 함수를 인자로 넣어 주면 됐다. 그렇다면 첫번쨰 화면인 메인화면에서 어떻게 프래그먼트에서 구현한 함수를 가져 올 수 있을까?
## 프래그먼트에서 구현한 함수, 액티비티에서 실행하기
// 메인 리사이클러뷰 설정
videoAdapter = VideoAdpater(mClickListener = { title,url ->
supportFragmentManager.fragments.find { it is PlayerFragment }?.let {
(it as PlayerFragment).playVideo(title,url)
}
})
프래그먼트 매니저를 통하여 모든 프래그먼트를 가져온후, PlayerFragment를 찾는다. 그리고 it은 fragment임으로 as를 통해 형변화를 해주고, 함수를 실행시켜주면 된다. 나름 잡기술이다.
# ExoPlayer 사용하기
ExoPlayer를 사용하기 위하여 gradle에 추가해준다.
//exoPlayer
implementation 'com.google.android.exoplayer:exoplayer:2.18.0'
init함수를 작성해준다. 이후 백그라운드에서도 재생되는 것을 막기 위해서 생명주기에 맞게 작성해준다.
// 생명주기를 활용하기 위해 null로 선언
private var player : ExoPlayer? = null
private fun initPlayer(fragmentPlayerBinding: FragmentPlayerBinding){
context?.let {
player = ExoPlayer.Builder(it).build()
}
fragmentPlayerBinding.videoPlayerView.player = player
}
// 백그라운드(홈버튼)에서는 Pause해주기 위해서
override fun onStop() {
super.onStop()
player?.pause()
}
// 프래그먼트생명주기가 끝나면 메모리 해제
override fun onDestroy() {
super.onDestroy()
binding = null
player?.release()
}
# ExoPlayer작동 함수
fun playVideo(title : String, url : String) {
context?.let {
val dataSourceFactory = DefaultDataSourceFactory(it)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
player?.let{
it.setMediaSource(mediaSource)
it.prepare()
it.play()
}
}
binding?.let {
it.playerMotionLayout.transitionToEnd()
it.titleTextView.text = title
}
}
# 모션레이아웃에 Key Position속성 주기
스와이프를 10%정도만 올려도 ExoPlayer가 꽉차도록 수정해보자.
여기서 키 포지션 속성을 주면 된다.
<KeyPosition
motion:motionTarget="@+id/videoPlayerView"
motion:framePosition="10"
motion:keyPositionType="deltaRelative"
motion:percentX="1"
motion:curveFit="linear"
motion:percentWidth="1"/>
# 끝내며
다양한 속성들이 있다는 사실을 공부하면서 하나씩 배우고 있다. 하루 빨리 디자인패턴을 공부하고 싶은 마음이 굴뚝같지만, 아직 안드로이드에서 활요한 기능들이 얼마 없기때문에, 듣고 있는 강의를 다 들으려고 한다. 원래 방학 계획에는 알고리즘+안드로이드 아키텍처 였는데 계획대로 흘러가지는 않는것 같다.. 그래도 하루에 2시간 이상씩은 안드 공부를 하고 있다. 깃허브를 파서 커밋을 계속할걸 그랬다..