개요
전에 프로젝트를 진행하면서 플립을 타켓으로 한 UI를 만든 적이 있다.
그 때 당시 구현에만 온 신경을 쓰느라 정신없이 하다가 리마인드 할 겸 정리하고자 한다.
실습 코드
기본 레이아웃
프로젝트를 생성한 뒤에 기본 레이아웃에서 TextView를 두개 만들고 Weight로 8:2의 화면을 만들었다.
이번 예시에서는 이를 화면이 접혔을 때 5:5로 되도록 해볼 것이다.


구현
반응형 UI 구현의 핵심은 Jetpack Window Manager다.
우선 모듈 Gradle에 위 사이트에서 제공하는 dependency를 받는다
필자가 들어갔을 당시 최신 버전
그리고 사용할 곳에서 아래와 같이 WindowInfoTracker 객체를 생성한다.
val windowInfoTracker = WindowInfoTracker.getOrCreate(this)
그리고 windowLayout의 상황을 windowLayoutInfo함수로 Flow로 반환 받을 수 있는데 이를 collect로 구독한다.
Flow를 구독해야 하므로 lifecycleScope를 이용해서 생명주기가 끝나면 자연스럽게 같이 소멸되도록 한다.
collect를 하게 되면 힌지 등에 대한 정보가 있는 WindowLayoutInfo가 들어오고, 이 값에 따라 분기처리를 하는 것이 핵심이다.
newLayoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
을 통해 폴더블에 대한 값을 가져오고 폴더블이 아니라 없을 경우에는 null을 반환하도록 한다.
여기서부터는 코드가 길어지는 관계로 통째로 첨부해서 진행하도록 하겠다.
// file: "MainActivity.kt"
val windowInfoTracker = WindowInfoTracker.getOrCreate(this)
lifecycleScope.launch {
// WindowLayoutInfo에 대한 정보를 수집
windowInfoTracker.windowLayoutInfo(this@MainActivity)
.collect { newLayoutInfo ->
/**
* 폴더블 디바이스에 존재하는 정보에 대한 것을 가져옴
* 만약 폴더블이 아니라서 없을 경우에는 Null을 반환하게 함
*/
val foldingFeature =
newLayoutInfo.displayFeatures.filterIsInstance<FoldingFeature>()
.firstOrNull()
/**
* 폴더블이 맞을 경우에는 관련 상태에 따라 작업을 진행하도록 함
*/
foldingFeature?.let {
/**
* 만약 접힌 상태라면
*/
if (it.state == FoldingFeature.State.HALF_OPENED) {
/**
* 힌지의 영역을 알아냄
*/
val hingeBounds = it.bounds
/**
* 만약 힌지가 세로로 있는 거라면 => 폴드라면
*/
val isVerticalHinge = hingeBounds.height() >= hingeBounds.width()
if (isVerticalHinge) {
ConstraintSet().apply {
clone(binding.main)
this.setHorizontalWeight(binding.blue.id, 0.5F)
this.setHorizontalWeight(binding.green.id, 0.5F)
applyTo(binding.main)
}
}
/**
* 만약 힌지가 가로로 있는 거라면 => 플립이라면
*/
else{
// 플립을 현재 테스트 할 기기가 없는 관계로...
}
}
/**
* 만약 아니라면 => 다시 폴드의 다 접었을 떄 쓰는 화면 or 펼쳐진 상태라면 원래대로 복구
*/
else {
val hingeBounds = it.bounds
val isVerticalHinge = hingeBounds.height() >= hingeBounds.width()
if (isVerticalHinge) {
ConstraintSet().apply {
clone(binding.main)
this.setHorizontalWeight(binding.blue.id, 0.8F)
this.setHorizontalWeight(binding.green.id, 0.2F)
applyTo(binding.main)
}
}
}
}
}
}
여기서 핵심은 결국 Layout이 변할 때마다 Layout을 수정하는 코드를 작성해야 한다.
필자의 경우 ConstraintLayout이었고, weight를 이용하여 8:2로 설정한 것이었기 때문에 접힘 상태임이 확인될 경우 이를 5:5로 바꿔주었다.
우선 기본적인 것은 완성이 되었지만 실제로 본 코드로 테스트를 해보면
로보트 춤을 연상시키는 것 같은 뚝딱뚝딱거리는 모습…
위와 같이 Layout이 그냥 변한 것을 그대로 다시 보여줄 뿐이기에 미관적으로 좋지 않은 모습이 나온다.
과거 본인은 이를 해결하고자 ValueAnimator를 이용하여 ConstraintLayout을 적용했을 때 이동할 픽셀 값을 계산해서 애니메이션 효과를 준 뒤, ConstraintLayout을 적용하는 노력(환상의 노가다)을 하였으나 전혀 그럴 필요가 없다.
이때까지 내 노력은…?
TransitionManager라는 것을 이용해서 아주 간편하게 적용할 수 있다.
TransitionManager.beginDelayedTransition(binding.main)와 같이 화면이 바뀌는 곳의 조상 지점을 넘기기만 하면 애니메이션이 적용된다.
// File: "애니메이션 적용 코드"
ConstraintSet().apply {
clone(binding.main)
TransitionManager.beginDelayedTransition(binding.main)
this.setHorizontalWeight(binding.blue.id, 0.5F)
this.setHorizontalWeight(binding.green.id, 0.5F)
applyTo(binding.main)
}
애니메이션? 어렵지 않아요~ㅠ
마무리
이렇게 아주 간단한 반응형 UI를 만들어 보았다.
생각보다 간단하다면 간단하지만 실제 어플 화면의 경우 위처럼 단순하지 않고, 접힘 상태에 따라 보이거나 보이지 않게 숨기거나, 레이아웃을 아예 변경하는 등의 대규모 작업이 들어가게 될 경우 레이아웃 작업이 많아지게 된다.
본 예시는 XML 레이아웃을 기반으로 한 작업이었기 때문에 애니메이션 적용이 훨씬 가벼워졌다고 하더라도 쉽지 않을 것이다.
이는 내가 다음 프로젝트에서 과감하게 컴포즈를 도전하게 된 이유이기도 하다.
다음에는 컴포즈로 작성하면 얼마나 간편하게 동작하는지에 대해 써보도록 하겠다.