이번 글에서 언급하는 삽질은 특수 상황에서의 해결 방법을 다룹니다.
샘플 코드 : https://github.com/Pluu/TouchableSample
안드로이드 개발 시에 사용자에게 유효한 터치 영역을 제공하는 것은 UI&UX에서 필수적으로 챙겨야 하는 부분입니다.
터치할 수 있는 크기는 플랫폼마다 다르지만, 모바일 플랫폼에서는 아래처럼 안내하고 있습니다.
Material Design > Touch targets : https://material.io/design/layout/spacing-methods.html#touch-targets
Human Interface Guidelines > Layout : https://developer.apple.com/design/human-interface-guidelines/foundations/layout/#best-practices
안드로이드에서는 View를 특정한 위치에 두는 방법으로는 padding/margin/translation 등의 방법을 사용합니다.
Padding을 적용한 ImageButton | Margin만 적용한 ImageButton |
---|---|
터치할 수 있는 Component(예: Button/ImageButton)라면, 유효한 터치 영역 지정으로 View에 padding
속성을 지정하는 것이 흔하게 사용되는 방법입니다. 그 외에도 TouchDelegate/HitRect도 존재합니다.
문제는 이것으로 끝나지 않습니다. 회사/프로젝트마다 디자인 가이드에 유효 컴포넌트 영역이 없는 경우도 자주 경험한 사례도 많습니다. 이 경우 개발자 스스로가 View 끼리 위치 조건에 따라 padding과 margin 등을 잘 계산해서 적용합니다.
먼저 최종 결과물부터 보겠습니다. 클릭 시 좌우 X버튼의 차이를 크게 느끼지 못하실겁니다.
유사하게 보이도록 하기 위해서는 아래와 같이 몇 가지의 작업이 필요합니다.
XML에 정의된 좌우 케이스의 다른 점은 padding/margin 사용 여부입니다. 좌측은 Padding을 사용했고, 우측은 터치 영역은 고려하지 않고 위치만 동일하게 맞추기 위해서 margin 속성을 사용했습니다.
<!-- Padding으로 위치+터치 영역 확보 -->
<androidx.cardview.widget.CardView
...>
<androidx.constraintlayout.widget.ConstraintLayout
..>
...
<ImageButton
...
android:padding="12dp"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
<!-- Margin만으로 위치 확보 -->
<androidx.cardview.widget.CardView
...>
<androidx.constraintlayout.widget.ConstraintLayout
..>
...
<ImageButton
...
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
Padding을 적용한 ImageButton | Margin만 적용한 ImageButton |
---|---|
뷰에는 Hit 영역을 커스텀하여 반환할 수 있는 View#getHitRect 함수가 존재합니다. 다만, get만 존재하고 set은 존재하지 않습니다. View#getHitRect 함수도 API Level 1부터 존재했던 함수라서, 뷰가 직접 선택 가능 영역을 처리하도록 제공된다는 것을 알 수 있습니다.
자체 커스텀 뷰가 아니라면 유연하게 사용하기 어려우므로 이번 케이스에서는 사용 불가능합니다.
Android에는 상위 ViewGroup이 하위 View bounds 밖으로 터치 가능 영역을 확장할 수 있게 하는 TouchDelegate 클래스를 제공합니다. TouchDelegate 클래스는 하위 View의 터치 영역을 확대/축소해야하는 경우에 유용합니다.
parentView.touchDelegate = TouchDelegate(/** Hit size */, /** Child View */)
실제 사용도 하위 View가 아니라 상위 ViewGroup의 touchDelegate 함수를 사용합니다.
이어서 코드를 살펴보겠습니다.
// 샘플
private fun setUpViews() {
// Expand Hit Size
val addHitSize = 12.dp2px() // 추가로 Hit하려는 크기 (기존 Padding으로 추가한 사이즈)
val targetView = binding.changeButton
val parentView = targetView.parent as View
parentView.doOnPreDraw { parent ->
val updateHitRect = Rect().also { r ->
targetView.getHitRect(r)
r.inset(-addHitSize, -addHitSize)
}
parent.touchDelegate = TouchDelegate(updateHitRect, targetView) // <-- 핵심 코드
}
}
중요한 것은 하위 View의 HitRect를 가져와서 원하는 크기만큼 변경 후, 상위 ViewGroup의 touchDelegate
에 TouchDelegate 클래스의 인스턴스를 설정하면 됩니다. 뷰의 크기를 알아야 하므로 위치/크기를 알 수 있는 시점에서 HitRect
를 취득하면 됩니다.
다만, TouchDelegate는 XML로 적용은 되지 않으며 코드에서만 적용할 수 있습니다.
여기까지 작업 시 하위 View 영역 밖에서 클릭하더라도 클릭 효과를 얻을 수 있습니다.
하지만 클릭 시 Ripple 효과가 일어나는 영역이 달라서 어색하게 보입니다.
Padding만 적용 시 클릭 효과 | TouchDelegate 적용 시 클릭 효과 |
---|---|
대부분의 동작은 해결했으니, 이제 남은 것은 Ripple 크기입니다. 샘플에서는 단순하게 RippleDrawable#setRadius으로 기본 Riadius보다 더 크게 수정해줍니다.
private fun setUpViews() {
val targetView = binding.changeButton
...
targetView.ripple(/** Update size */)
}
// Ripple 사이즈 수정
fun ImageButton.ripple(addSize: Int) {
doOnPreDraw {
val drawable = background
if (drawable is RippleDrawable) {
// (샘플) 버튼 크기 + 추가 크기 기준으로 반지름 계산
drawable.radius = (width + addSize * 2) / 2
}
background = drawable
}
}
RippleDrawable 수정 전 | RippleDrawable 수정 후 | 기존 Padding만 적용 |
---|---|---|
지금까지 언급한 내용을 적용하면 Padding과 유사한 모습을 볼 수 있습니다.
HitRect와 TouchDelegate 커스텀은 몇 가지의 제약은 존재하지만 잘 사용한다면 꽤 괜찮은 결과물을 만들어 낼 수 있습니다.
위와 같은 작업을 여러 곳에서 유연하게 적용하기에는 추가로 고려해야 할 사항이 있습니다.
ViewGroup에 TouchDelegate를 적용하는 것은 하나만 가능하므로, 여러 View들은 별도 처리가 필요합니다.
Github 샘플에서는 다음 Stackoverflow 해결법을 사용했습니다.
https://stackoverflow.com/a/20051314
TouchDelegate는 XML에서 적용할 수 있는 수단은 없으므로, DataBinding으로 해결할 수 있습니다. 또한, 여전히 하위 View 처리 이슈도 남아 있습니다.
comments powered by Disqus
Subscribe to this blog via RSS.