사용 의도
- 스타포스 UI는 아이템별로 배치해야 할 별의 위치, 개수, 심지어 별의 간격까지 다르며 이것을 정형화된 View로는 나타낼 수 없다고 판단하여 Custom View를 활용하기로 하였다.
학습 내용
Custom View의 구현은 View의 크기를 측정하는 onMeasure(), View를 측정한 후 화면에 배치한 후 호출되는 onLayout(), View를 배치한 후 View에 무언가를 그리는 onDraw()로 순차적으로 이루어진다.
- onMeasure() : View의 크기를 확인하거나 ViewGroup인 경우 ViewGroup을 구성하는 Child View들을 통하여 width와 height를 결정한다.
- onMeasure()는 특별히 return하는 값이 없으며, setMeasuredDimension()을 호출하여 width와 height를 명시적으로 결정한다.
- onLayout() : View를 측정한 후 화면에 배치한 뒤 호출된다.
- ViewGroup은 이 단계에서 Child View의 위치를 배치할 수 있다.
- onDraw() : 매개변수로써 주어지는 Canvas와 Paint를 사용하여 필요한 내용을 그릴 수 있다.
- Canvas : 그래픽 함수를 제공하는 클래스이며, draw() 메소드로 그림을 그리면 된다.
- Paint : 그리기 위해 필요한 색상이나 투명도 등의 옵션을 결정하는 클래스이다.
적용 방법
스타포스 Custom View 구현을 위해 속성과 스타일을 정의한다.
<style name="Starforce" />
<style name="Starforce.StarforceViewStyle">
<item name="starWidth">14dp</item>
<item name="starHeight">14dp</item>
</style>
<style name="Starforce.StarforceItemViewStyle">
<item name="starOnColor">@drawable/ic_starforce_yellow</item>
<item name="starOffColor">@drawable/ic_starforce_none</item>
<item name="starBlueColor">@drawable/ic_starforce_blue</item>
</style>
<declare-styleable name="StarforceView">
<attr name="starforceViewStyle" format="reference" />
<attr name="starforceItemViewStyle" format="reference" />
<attr name="starWidth" format="dimension" />
<attr name="starHeight" format="dimension" />
<attr name="starOnColor" format="reference|integer" />
<attr name="starOffColor" format="reference|integer" />
<attr name="starBlueColor" format="reference|integer" />
</declare-styleable>
- 스타포스 구현에는 별의 너비와 높이, 그리고 별의 종류만 정의해두면 된다.
다음으로, 스타포스를 구성하는 별을 나타낼 View를 선언한다.
class StarforceItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.starforceItemViewStyle,
defStyleRes: Int = R.style.Starforce_StarforceItemViewStyle,
starType: Int = 0
) : View(ContextThemeWrapper(context, defStyleRes), attrs, defStyleAttr) {
init {
context.withStyledAttributes(attrs, R.styleable.StarforceView, defStyleAttr, defStyleRes) {
when (starType) {
0 -> {
setBackgroundResource(R.drawable.ic_starforce_none)
}
1 -> {
setBackgroundResource(R.drawable.ic_starforce_yellow)
}
2 -> {
setBackgroundResource(R.drawable.ic_starforce_blue)
}
}
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
Timber.d("Drawing Star")
}
}
- 스타포스를 나타내는 별 이미지 말고는 필요하지 않기 때문에 별 이미지를 배경으로 지정하는 코드 말고는 필요하지 않다.
마지막으로 여러 개의 별 View를 구성할 ViewGroup을 구현한다.
class StarforceFirstRowView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.starforceViewStyle,
defStyleRes: Int = R.style.Starforce_StarforceViewStyle
) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) {
private var _starWidth: Float = 0F
private var _starHeight: Float = 0F
private var _childCount: Float = 16F
init {
context.withStyledAttributes(attrs, R.styleable.StarforceView, defStyleAttr, defStyleRes) {
_starWidth = getDimension(R.styleable.StarforceView_starWidth, 0F)
_starHeight = getDimension(R.styleable.StarforceView_starHeight, 0F)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val measuredWidth = paddingStart + paddingEnd + max(
suggestedMinimumWidth,
(_starWidth * _childCount).toInt()
)
val measuredHeight = paddingTop + paddingBottom + max(
suggestedMinimumHeight,
_starHeight.toInt()
)
setMeasuredDimension(measuredWidth, measuredHeight)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
val childWidth = ((width * 2) / (_childCount * 2)).toInt()
var childLeft = 0
for ((index, child) in children.withIndex()) {
child.layout(
childLeft,
0,
(childLeft + childWidth),
height
)
childLeft += childWidth
if ((index + 1) % 5 == 0) {
childLeft += (childWidth / 2)
}
}
}
private fun setChildCount(firstMaxStarforceValue: Int) {
_childCount = 0F
if (firstMaxStarforceValue >= 6) {
_childCount += 0.5F
}
if (firstMaxStarforceValue >= 11) {
_childCount += 0.5F
}
_childCount += firstMaxStarforceValue.toFloat()
}
fun initStarforceView(
starforceValue: Int,
maxStarforceValue: Int,
isStarforceScrollUsed: Boolean
) {
val firstMaxStarforceValue = if (isStarforceScrollUsed) 15 else min(15, maxStarforceValue)
setChildCount(firstMaxStarforceValue)
val firstStarforceValue = min(15, starforceValue)
requestLayout()
for (starforce in 0 until firstStarforceValue) {
addView(
StarforceItemView(
context = context,
starType = if (isStarforceScrollUsed) 2 else 1
)
)
}
for (starforce in firstStarforceValue until firstMaxStarforceValue) {
addView(
StarforceItemView(
context = context
)
)
}
}
}
class StarforceSecondRowView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.starforceViewStyle,
defStyleRes: Int = R.style.Starforce_StarforceViewStyle
) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) {
private var _starWidth: Float = 0F
private var _starHeight: Float = 0F
private var _childCount: Float = 10.5F
init {
context.withStyledAttributes(attrs, R.styleable.StarforceView, defStyleAttr, defStyleRes) {
_starWidth = getDimension(R.styleable.StarforceView_starWidth, 0F)
_starHeight = getDimension(R.styleable.StarforceView_starHeight, 0F)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val measuredWidth = paddingStart + paddingEnd + max(
suggestedMinimumWidth,
(_starWidth * _childCount).toInt()
)
val measuredHeight = paddingTop + paddingBottom + max(
suggestedMinimumHeight,
_starHeight.toInt()
)
setMeasuredDimension(measuredWidth, measuredHeight)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
val childWidth = ((width * 2) / (_childCount * 2)).toInt()
var childLeft = 0
for ((index, child) in children.withIndex()) {
child.layout(
childLeft,
0,
(childLeft + childWidth),
height
)
childLeft += childWidth
if ((index + 1) % 5 == 0) {
childLeft += (childWidth / 2)
}
}
}
private fun setChildCount(firstMaxStarforceValue: Int) {
_childCount = 0F
if (firstMaxStarforceValue >= 6) {
_childCount += 0.5F
}
_childCount += firstMaxStarforceValue.toFloat()
}
fun initStarforceView(starforceValue: Int, maxStarforceValue: Int) {
val firstMaxStarforceValue = min(10, maxStarforceValue - 15)
setChildCount(firstMaxStarforceValue)
val firstStarforceValue = min(10, starforceValue - 15)
requestLayout()
for (starforce in 0 until firstStarforceValue) {
addView(
StarforceItemView(
context = context,
starType = 1
)
)
}
for (starforce in firstStarforceValue until firstMaxStarforceValue) {
addView(
StarforceItemView(
context = context
)
)
}
}
}
- 먼저 필요한 별 View의 총합 개수에 따라 ViewGroup의 width를 다시 측정할 것이다.
- childCount는 ViewGroup에 추가될 별 View의 개수를 값으로 가지는 변수이며, childCount의 값에 따라서 ViewGroup의 width가 정해진다.
- 다만 5의 배수번째 별과 그 다음 별 간의 간격이 약간 존재한다. 이를 위해 ViewGroup의 width를 별 View의 width의 절반만큼 늘리기 위하여 childCount를 0.5만큼 더하였다.
- childCount를 초기화하였다면 requestLayout()을 호출하여 ViewGroup의 width와 height부터 다시 측정한다.
- onMeasure()에서 ViewGroup의 width와 height를 측정한다.
- width는 별 View의 width를 의미하는 starWidth와 별 View의 개수를 의미하는 childCount를 곱한 값이 되며, height는 별 View의 height가 된다.
- onMeasure()에서 ViewGroup의 width와 height를 측정한다.
- onLayout()에서는 ViewGroup에 포함될 자식 View들의 위치를 정한다.
- 첫 번째 별 View의 left와 top은 ViewGroup의 left와 top과 일치하게 되며, 그 다음 별 View부터는 left는 별 View의 width만큼 증가하게 된다.
- 여기서 5의 배수번째 별과 그 다음 별 간의 간격이 존재하기 때문에 그 다음 별의 left는 별 View의 width의 1.5배만큼 증가한다.
- StarforceFirstRowView는 15성까지를 나타내는 첫 번째 스타포스 ViewGroup이며, StarforceSecondRowView는 16성부터 25성까지를 나타내는 두 번째 스타포스 ViewGroup이다.
- 2개의 ViewGroup으로 나눈 이유는, 두 ViewGroup에 포함될 별의 개수의 최댓값도 다르고 시작점도 다르기 때문에, onLayout() 단계에서 별 View의 위치를 측정하기가 까다롭다고 판단하였다.
- 또한 어차피 15성 이하의 스타포스까지만 올릴 수 있는 아이템도 존재하기 때문에, ViewGroup을 2개로 분리하는 것이 낫다고 판단하였다.
<com.bodan.maplecalendar.presentation.views.equipment.StarforceFirstRowView
android:id="@+id/starforce_first_item_equipment_detail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{vm.characterLastItemEquipment.isFirstStarforceVisible ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toTopOf="@id/starforce_second_item_equipment_detail"
app:layout_constraintEnd_toStartOf="@id/gl_right_item_equipment_detail"
app:layout_constraintStart_toEndOf="@id/gl_left_item_equipment_detail"
app:layout_constraintTop_toBottomOf="@id/gl_top_item_equipment_detail" />
<com.bodan.maplecalendar.presentation.views.equipment.StarforceSecondRowView
android:id="@+id/starforce_second_item_equipment_detail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginBottom="4dp"
android:visibility="@{vm.characterLastItemEquipment.isSecondStarforceVisible ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toTopOf="@id/tv_soul_item_equipment_detail"
app:layout_constraintEnd_toStartOf="@id/gl_right_item_equipment_detail"
app:layout_constraintStart_toEndOf="@id/gl_left_item_equipment_detail"
app:layout_constraintTop_toBottomOf="@id/starforce_first_item_equipment_detail" />
class ItemEquipmentDetailFragment : BaseDialogFragment<FragmentItemEquipmentDetailBinding>(R.layout.fragment_item_equipment_detail) {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
...
initStarforce()
...
}
...
private fun initStarforce() {
viewModel.characterLastItemEquipment.value?.let { itemEquipment ->
if (itemEquipment.maxStarforceValue >= 1) {
binding.starforceFirstItemEquipmentDetail.initStarforceView(
itemEquipment.itemStarforce.toInt(),
itemEquipment.maxStarforceValue,
itemEquipment.isStarforceScrollUsed
)
}
if (itemEquipment.maxStarforceValue >= 16) {
binding.starforceSecondItemEquipmentDetail.initStarforceView(
itemEquipment.itemStarforce.toInt(),
itemEquipment.maxStarforceValue
)
}
}
}
}
- 마지막으로 장비 세부정보 UI에 앞서 구현한 Custom ViewGroup 2개를 배치하고, Kotlin 내부에서 ViewGroup에서 정의한 initStarforceView() 함수를 호출하여 별 View를 ViewGroup에 추가한다.
결과
코드
'개인 프로젝트 > 안드로이드' 카테고리의 다른 글
[메이플 캘린더] ViewPager2와 TabLayout으로 하이퍼스탯, 어빌리티 프리셋을 좌우 스와이프로 확인해보자 (2) | 2024.05.18 |
---|---|
[메이플 캘린더] Hilt와 Clean Architecture (0) | 2024.05.03 |
[메이플 캘린더] ViewPager2와 Custom View로 캐릭터 정보 조회 날짜를 선택하는 달력을 만들어보자 (0) | 2024.04.26 |
[메이플 캘린더] 위로 스와이프 시 새로고침 기능을 구현해보자 (0) | 2024.01.24 |
[메이플 캘린더] RecyclerView로 원하는 달력 View를 만들어보자 (0) | 2024.01.22 |