feat(widget): 크리에이터 랭킹 위젯을 추가한다
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CreatorRankingAdapter(
|
||||
private val onClickItem: (CreatorRankingItem) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<CreatorRankingItem>()
|
||||
|
||||
override fun getItemViewType(position: Int): Int = when (CreatorRankingPlacement.fromRank(items[position].rank).variant) {
|
||||
CreatorRankingCardVariant.Large -> VIEW_TYPE_LARGE
|
||||
CreatorRankingCardVariant.Compact -> VIEW_TYPE_COMPACT
|
||||
CreatorRankingCardVariant.Horizontal -> VIEW_TYPE_HORIZONTAL
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_LARGE -> LargeViewHolder(
|
||||
inflater.inflate(R.layout.view_creator_ranking_large_card, parent, false) as CreatorRankingLargeCardView,
|
||||
parent
|
||||
)
|
||||
VIEW_TYPE_COMPACT -> CompactViewHolder(
|
||||
inflater.inflate(R.layout.view_creator_ranking_compact_card, parent, false) as CreatorRankingCompactCardView,
|
||||
parent
|
||||
)
|
||||
VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder(
|
||||
inflater.inflate(R.layout.view_creator_ranking_horizontal_card, parent, false) as CreatorRankingHorizontalCardView,
|
||||
parent
|
||||
)
|
||||
else -> error("Unknown viewType: $viewType")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
when (holder) {
|
||||
is LargeViewHolder -> holder.bind(item)
|
||||
is CompactViewHolder -> holder.bind(item)
|
||||
is HorizontalViewHolder -> holder.bind(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun submitItems(items: List<CreatorRankingItem>) {
|
||||
this.items.clear()
|
||||
this.items.addAll(items)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private inner class LargeViewHolder(
|
||||
private val view: CreatorRankingLargeCardView,
|
||||
private val parent: ViewGroup
|
||||
) : RecyclerView.ViewHolder(view) {
|
||||
fun bind(item: CreatorRankingItem) {
|
||||
bindCommon(view, item, parent)
|
||||
view.bind(item)
|
||||
view.setOnCreatorClick(onClickItem)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CompactViewHolder(
|
||||
private val view: CreatorRankingCompactCardView,
|
||||
private val parent: ViewGroup
|
||||
) : RecyclerView.ViewHolder(view) {
|
||||
fun bind(item: CreatorRankingItem) {
|
||||
bindCommon(view, item, parent)
|
||||
view.bind(item)
|
||||
view.setOnCreatorClick(onClickItem)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class HorizontalViewHolder(
|
||||
private val view: CreatorRankingHorizontalCardView,
|
||||
private val parent: ViewGroup
|
||||
) : RecyclerView.ViewHolder(view) {
|
||||
fun bind(item: CreatorRankingItem) {
|
||||
bindCommon(view, item, parent)
|
||||
view.bind(item)
|
||||
view.setOnCreatorClick(onClickItem)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindCommon(
|
||||
view: CreatorRankingLargeCardView,
|
||||
item: CreatorRankingItem,
|
||||
parent: ViewGroup
|
||||
) {
|
||||
val size = calculateSize(item, parent)
|
||||
view.setCardSize(size)
|
||||
view.imageView().loadCreatorImage(item)
|
||||
}
|
||||
|
||||
private fun bindCommon(
|
||||
view: CreatorRankingCompactCardView,
|
||||
item: CreatorRankingItem,
|
||||
parent: ViewGroup
|
||||
) {
|
||||
val size = calculateSize(item, parent)
|
||||
view.setCardSize(size)
|
||||
view.imageView().loadCreatorImage(item)
|
||||
}
|
||||
|
||||
private fun bindCommon(
|
||||
view: CreatorRankingHorizontalCardView,
|
||||
item: CreatorRankingItem,
|
||||
parent: ViewGroup
|
||||
) {
|
||||
val size = calculateSize(item, parent)
|
||||
view.setCardSize(size)
|
||||
view.imageView().loadCreatorImage(item)
|
||||
}
|
||||
|
||||
private fun ImageView.loadCreatorImage(item: CreatorRankingItem) {
|
||||
loadUrl(item.imageUrl) {
|
||||
val blurTransformations = CreatorRankingBlur.transformations(context, item.isInaccessible)
|
||||
if (blurTransformations.isNotEmpty()) {
|
||||
transformations(blurTransformations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSize(
|
||||
item: CreatorRankingItem,
|
||||
parent: ViewGroup
|
||||
): CreatorRankingCardSize {
|
||||
val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
|
||||
return CreatorRankingLayoutCalculator.calculate(
|
||||
parentWidthPx = parentWidth,
|
||||
parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight,
|
||||
horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent),
|
||||
placement = CreatorRankingPlacement.fromRank(item.rank)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(parent: ViewGroup): Int = (this * parent.resources.displayMetrics.density).roundToInt()
|
||||
|
||||
companion object {
|
||||
const val GRID_SPAN_COUNT = 6
|
||||
|
||||
fun createGridLayoutManager(context: Context): GridLayoutManager = GridLayoutManager(context, GRID_SPAN_COUNT).apply {
|
||||
spanSizeLookup = createSpanSizeLookup()
|
||||
}
|
||||
|
||||
fun createSpanSizeLookup(): GridLayoutManager.SpanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int = when (CreatorRankingPlacement.fromRank(position + 1).itemsPerRow) {
|
||||
1 -> GRID_SPAN_COUNT
|
||||
2 -> GRID_SPAN_COUNT / 2
|
||||
3 -> GRID_SPAN_COUNT / 3
|
||||
else -> GRID_SPAN_COUNT
|
||||
}
|
||||
}
|
||||
|
||||
private const val VIEW_TYPE_LARGE = 1
|
||||
private const val VIEW_TYPE_COMPACT = 2
|
||||
private const val VIEW_TYPE_HORIZONTAL = 3
|
||||
private const val HORIZONTAL_GAP_DP = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil.transform.Transformation
|
||||
import kr.co.vividnext.sodalive.common.image.BlurTransformation
|
||||
|
||||
internal object CreatorRankingBlur {
|
||||
|
||||
fun shouldUseCoilBlur(
|
||||
enabled: Boolean,
|
||||
sdkInt: Int = Build.VERSION.SDK_INT
|
||||
): Boolean = enabled && sdkInt < Build.VERSION_CODES.S
|
||||
|
||||
fun transformations(
|
||||
context: Context,
|
||||
enabled: Boolean,
|
||||
sdkInt: Int = Build.VERSION.SDK_INT
|
||||
): List<Transformation> = if (shouldUseCoilBlur(enabled, sdkInt)) {
|
||||
listOf(BlurTransformation(context, BLUR_RADIUS, BLUR_SAMPLING))
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private const val BLUR_RADIUS = 25f
|
||||
private const val BLUR_SAMPLING = 2.5f
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
enum class CreatorRankingCardVariant {
|
||||
Large,
|
||||
Compact,
|
||||
Horizontal
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
enum class CreatorRankingChangeType {
|
||||
Increase,
|
||||
Decrease,
|
||||
Stay,
|
||||
New
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CreatorRankingCompactCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var image: ImageView? = null
|
||||
private var dimGradient: View? = null
|
||||
private var deltaGroup: View? = null
|
||||
private var rankText: TextView? = null
|
||||
private var deltaAmountText: TextView? = null
|
||||
private var deltaIcon: ImageView? = null
|
||||
private var nameText: TextView? = null
|
||||
private var currentItem: CreatorRankingItem? = null
|
||||
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
image = findViewById(R.id.iv_creator_ranking_image)
|
||||
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
|
||||
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
|
||||
rankText = findViewById(R.id.tv_creator_ranking_rank)
|
||||
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
|
||||
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
|
||||
nameText = findViewById(R.id.tv_creator_ranking_name)
|
||||
clipToOutline = true
|
||||
outlineProvider = roundOutlineProvider()
|
||||
imageView().outlineProvider = roundOutlineProvider()
|
||||
imageView().clipToOutline = true
|
||||
}
|
||||
|
||||
fun bind(item: CreatorRankingItem) {
|
||||
currentItem = item
|
||||
requireNotNull(rankText).apply {
|
||||
text = item.rank.toString()
|
||||
applyCreatorRankingRankGradient()
|
||||
}
|
||||
bindDelta(item)
|
||||
requireNotNull(nameText).apply {
|
||||
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
||||
}
|
||||
dimGradient?.visibility = View.VISIBLE
|
||||
applyAccessState(item)
|
||||
}
|
||||
|
||||
fun setCardSize(size: CreatorRankingCardSize) {
|
||||
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
|
||||
width = size.widthPx
|
||||
height = size.heightPx
|
||||
}
|
||||
positionViews(size)
|
||||
}
|
||||
|
||||
fun imageView(): ImageView = requireNotNull(image)
|
||||
|
||||
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
|
||||
clickListener = listener
|
||||
currentItem?.let(::applyAccessState)
|
||||
}
|
||||
|
||||
private fun bindDelta(item: CreatorRankingItem) {
|
||||
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||
applyDeltaContainer(presentation)
|
||||
requireNotNull(deltaIcon).apply {
|
||||
setImageResource(presentation.iconRes)
|
||||
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||
width = presentation.iconWidthDp.dpToPx()
|
||||
height = presentation.iconHeightDp.dpToPx()
|
||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
||||
}
|
||||
}
|
||||
requireNotNull(deltaAmountText).apply {
|
||||
text = presentation.amountText.orEmpty()
|
||||
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
||||
requireNotNull(deltaGroup).apply {
|
||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyAccessState(item: CreatorRankingItem) {
|
||||
applyBlur(item.isInaccessible)
|
||||
isClickable = item.isTouchable && clickListener != null
|
||||
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
|
||||
}
|
||||
|
||||
private fun positionViews(size: CreatorRankingCardSize) {
|
||||
if (size.widthPx <= SMALL_THRESHOLD_PX) {
|
||||
requireNotNull(rankText).textSize = 40f
|
||||
requireNotNull(nameText).textSize = 14f
|
||||
positionSmall(size)
|
||||
} else {
|
||||
requireNotNull(rankText).textSize = 54f
|
||||
requireNotNull(nameText).textSize = 22f
|
||||
positionMedium(size)
|
||||
}
|
||||
requireNotNull(rankText).applyCreatorRankingRankGradient()
|
||||
}
|
||||
|
||||
private fun positionMedium(size: CreatorRankingCardSize) {
|
||||
val scale = size.widthPx / 185f
|
||||
requireNotNull(rankText).layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt())
|
||||
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
leftMargin = (10 * scale).roundToInt()
|
||||
topMargin = (70 * scale).roundToInt()
|
||||
}
|
||||
requireNotNull(nameText).layoutParams = LayoutParams((165 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
leftMargin = ((size.widthPx - (165 * scale)) / 2f).roundToInt()
|
||||
topMargin = (145 * scale).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun positionSmall(size: CreatorRankingCardSize) {
|
||||
val scale = size.widthPx / 122f
|
||||
requireNotNull(rankText).layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply {
|
||||
leftMargin = (8 * scale).roundToInt()
|
||||
}
|
||||
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
leftMargin = (10 * scale).roundToInt()
|
||||
topMargin = (49 * scale).roundToInt()
|
||||
}
|
||||
requireNotNull(nameText).layoutParams = LayoutParams((102 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
leftMargin = ((size.widthPx - (102 * scale)) / 2f).roundToInt()
|
||||
topMargin = (98 * scale).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyBlur(enabled: Boolean) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
imageView().setRenderEffect(
|
||||
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
||||
|
||||
private companion object {
|
||||
const val SMALL_THRESHOLD_PX = 140
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
data class CreatorRankingDeltaPresentation(
|
||||
@get:DrawableRes val iconRes: Int,
|
||||
val showAmount: Boolean,
|
||||
val amountText: String?,
|
||||
val showPillBackground: Boolean,
|
||||
val iconWidthDp: Int,
|
||||
val iconHeightDp: Int,
|
||||
val iconMarginStartDp: Int
|
||||
) {
|
||||
companion object {
|
||||
fun from(
|
||||
type: CreatorRankingChangeType,
|
||||
amount: Int
|
||||
): CreatorRankingDeltaPresentation = when (type) {
|
||||
CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation(
|
||||
iconRes = R.drawable.ic_rank_caret_increase,
|
||||
showAmount = true,
|
||||
amountText = amount.toString(),
|
||||
showPillBackground = true,
|
||||
iconWidthDp = CARET_ICON_SIZE_DP,
|
||||
iconHeightDp = CARET_ICON_SIZE_DP,
|
||||
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
|
||||
)
|
||||
CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
|
||||
iconRes = R.drawable.ic_rank_caret_decrease,
|
||||
showAmount = true,
|
||||
amountText = amount.toString(),
|
||||
showPillBackground = true,
|
||||
iconWidthDp = CARET_ICON_SIZE_DP,
|
||||
iconHeightDp = CARET_ICON_SIZE_DP,
|
||||
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
|
||||
)
|
||||
CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation(
|
||||
iconRes = R.drawable.ic_rank_caret_stay,
|
||||
showAmount = false,
|
||||
amountText = null,
|
||||
showPillBackground = true,
|
||||
iconWidthDp = CARET_ICON_SIZE_DP,
|
||||
iconHeightDp = CARET_ICON_SIZE_DP,
|
||||
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
|
||||
)
|
||||
CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation(
|
||||
iconRes = R.drawable.ic_rank_new,
|
||||
showAmount = false,
|
||||
amountText = null,
|
||||
showPillBackground = false,
|
||||
iconWidthDp = 36,
|
||||
iconHeightDp = 23,
|
||||
iconMarginStartDp = 0
|
||||
)
|
||||
}
|
||||
|
||||
private const val CARET_ICON_SIZE_DP = 14
|
||||
private const val CARET_ICON_MARGIN_START_DP = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CreatorRankingHorizontalCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var rankGroup: View? = null
|
||||
private var image: ImageView? = null
|
||||
private var rankText: TextView? = null
|
||||
private var deltaAmountText: TextView? = null
|
||||
private var deltaIcon: ImageView? = null
|
||||
private var nameText: TextView? = null
|
||||
private var currentItem: CreatorRankingItem? = null
|
||||
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
rankGroup = findViewById(R.id.ll_creator_ranking_rank_group)
|
||||
image = findViewById(R.id.iv_creator_ranking_image)
|
||||
rankText = findViewById(R.id.tv_creator_ranking_rank)
|
||||
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
|
||||
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
|
||||
nameText = findViewById(R.id.tv_creator_ranking_name)
|
||||
imageView().outlineProvider = roundOutlineProvider()
|
||||
imageView().clipToOutline = true
|
||||
}
|
||||
|
||||
fun bind(item: CreatorRankingItem) {
|
||||
currentItem = item
|
||||
requireNotNull(rankText).apply {
|
||||
text = item.rank.toString()
|
||||
applyCreatorRankingRankGradient()
|
||||
}
|
||||
bindDelta(item)
|
||||
requireNotNull(nameText).text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||
applyAccessState(item)
|
||||
}
|
||||
|
||||
fun setCardSize(size: CreatorRankingCardSize) {
|
||||
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
|
||||
width = size.widthPx
|
||||
height = size.heightPx
|
||||
}
|
||||
positionViews(size)
|
||||
}
|
||||
|
||||
fun imageView(): ImageView = requireNotNull(image)
|
||||
|
||||
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
|
||||
clickListener = listener
|
||||
currentItem?.let(::applyAccessState)
|
||||
}
|
||||
|
||||
private fun bindDelta(item: CreatorRankingItem) {
|
||||
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||
applyDeltaContainer(presentation)
|
||||
requireNotNull(deltaIcon).apply {
|
||||
setImageResource(presentation.iconRes)
|
||||
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||
width = presentation.iconWidthDp.dpToPx()
|
||||
height = presentation.iconHeightDp.dpToPx()
|
||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
||||
}
|
||||
}
|
||||
requireNotNull(deltaAmountText).apply {
|
||||
text = presentation.amountText.orEmpty()
|
||||
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
||||
findViewById<View>(R.id.ll_creator_ranking_delta).apply {
|
||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyAccessState(item: CreatorRankingItem) {
|
||||
applyBlur(item.isInaccessible)
|
||||
isClickable = item.isTouchable && clickListener != null
|
||||
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
|
||||
}
|
||||
|
||||
private fun positionViews(size: CreatorRankingCardSize) {
|
||||
val scale = size.widthPx / 374f
|
||||
requireNotNull(rankGroup).layoutParams = LayoutParams((49 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
leftMargin = (14 * scale).roundToInt()
|
||||
topMargin = (14 * scale).roundToInt()
|
||||
}
|
||||
requireNotNull(rankText).applyCreatorRankingRankGradient()
|
||||
imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply {
|
||||
leftMargin = (77 * scale).roundToInt()
|
||||
topMargin = (10 * scale).roundToInt()
|
||||
}
|
||||
requireNotNull(nameText).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
leftMargin = (171 * scale).roundToInt()
|
||||
topMargin = (39 * scale).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyBlur(enabled: Boolean) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
imageView().setRenderEffect(
|
||||
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
data class CreatorRankingItem(
|
||||
val creatorId: Long,
|
||||
val rank: Int,
|
||||
val previousRank: Int?,
|
||||
val rankChangeType: CreatorRankingChangeType,
|
||||
val rankChangeAmount: Int,
|
||||
val creatorName: String,
|
||||
val imageUrl: String,
|
||||
val isBlocked: Boolean
|
||||
) {
|
||||
init {
|
||||
require(rank >= 1) { "rank must be greater than or equal to 1." }
|
||||
}
|
||||
|
||||
val isInaccessible: Boolean = isBlocked
|
||||
|
||||
val isTouchable: Boolean = !isBlocked
|
||||
|
||||
fun displayName(inaccessibleMessage: String): String {
|
||||
if (!isInaccessible) return creatorName
|
||||
return if (rank <= 10) "" else inaccessibleMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CreatorRankingLargeCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var image: ImageView? = null
|
||||
private var dimGradient: View? = null
|
||||
private var deltaGroup: View? = null
|
||||
private var rankText: TextView? = null
|
||||
private var deltaAmountText: TextView? = null
|
||||
private var deltaIcon: ImageView? = null
|
||||
private var nameText: TextView? = null
|
||||
private var currentItem: CreatorRankingItem? = null
|
||||
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
image = findViewById(R.id.iv_creator_ranking_image)
|
||||
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
|
||||
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
|
||||
rankText = findViewById(R.id.tv_creator_ranking_rank)
|
||||
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
|
||||
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
|
||||
nameText = findViewById(R.id.tv_creator_ranking_name)
|
||||
clipToOutline = true
|
||||
outlineProvider = roundOutlineProvider()
|
||||
imageView().outlineProvider = roundOutlineProvider()
|
||||
imageView().clipToOutline = true
|
||||
}
|
||||
|
||||
fun bind(item: CreatorRankingItem) {
|
||||
currentItem = item
|
||||
requireNotNull(rankText).apply {
|
||||
text = item.rank.toString()
|
||||
applyCreatorRankingRankGradient()
|
||||
}
|
||||
bindDelta(item)
|
||||
requireNotNull(nameText).apply {
|
||||
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
||||
}
|
||||
dimGradient?.visibility = View.VISIBLE
|
||||
applyAccessState(item)
|
||||
}
|
||||
|
||||
fun setCardSize(size: CreatorRankingCardSize) {
|
||||
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
|
||||
width = size.widthPx
|
||||
height = size.heightPx
|
||||
}
|
||||
positionViews(size)
|
||||
}
|
||||
|
||||
fun imageView(): ImageView = requireNotNull(image)
|
||||
|
||||
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
|
||||
clickListener = listener
|
||||
currentItem?.let(::applyAccessState)
|
||||
}
|
||||
|
||||
private fun bindDelta(item: CreatorRankingItem) {
|
||||
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||
applyDeltaContainer(presentation)
|
||||
requireNotNull(deltaIcon).apply {
|
||||
setImageResource(presentation.iconRes)
|
||||
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||
width = presentation.iconWidthDp.dpToPx()
|
||||
height = presentation.iconHeightDp.dpToPx()
|
||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
||||
}
|
||||
}
|
||||
requireNotNull(deltaAmountText).apply {
|
||||
text = presentation.amountText.orEmpty()
|
||||
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
||||
requireNotNull(deltaGroup).apply {
|
||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyAccessState(item: CreatorRankingItem) {
|
||||
applyBlur(item.isInaccessible)
|
||||
isClickable = item.isTouchable && clickListener != null
|
||||
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
|
||||
}
|
||||
|
||||
private fun positionViews(size: CreatorRankingCardSize) {
|
||||
val scale = size.widthPx / FIGMA_SIZE.toFloat()
|
||||
requireNotNull(rankText).layoutParams = LayoutParams((112 * scale).roundToInt(), (124 * scale).roundToInt()).apply {
|
||||
leftMargin = 0
|
||||
topMargin = 0
|
||||
}
|
||||
requireNotNull(rankText).applyCreatorRankingRankGradient()
|
||||
requireNotNull(nameText).layoutParams = LayoutParams((334 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
leftMargin = ((size.widthPx - (334 * scale)) / 2f).roundToInt()
|
||||
topMargin = (305 * scale).roundToInt()
|
||||
}
|
||||
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
leftMargin = (20 * scale).roundToInt()
|
||||
topMargin = (122 * scale).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyBlur(enabled: Boolean) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
imageView().setRenderEffect(
|
||||
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
||||
|
||||
private companion object {
|
||||
const val FIGMA_SIZE = 374
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
object CreatorRankingLayoutCalculator {
|
||||
private const val HORIZONTAL_FIGMA_WIDTH = 374
|
||||
private const val HORIZONTAL_FIGMA_HEIGHT = 100
|
||||
|
||||
fun calculate(
|
||||
parentWidthPx: Int,
|
||||
parentHorizontalPaddingPx: Int = 0,
|
||||
horizontalGapPx: Int,
|
||||
placement: CreatorRankingPlacement
|
||||
): CreatorRankingCardSize {
|
||||
require(parentWidthPx > 0) { "parentWidthPx must be > 0." }
|
||||
require(parentHorizontalPaddingPx >= 0) { "parentHorizontalPaddingPx must be >= 0." }
|
||||
require(horizontalGapPx >= 0) { "horizontalGapPx must be >= 0." }
|
||||
require(placement.itemsPerRow > 0) { "itemsPerRow must be > 0." }
|
||||
|
||||
val totalGap = horizontalGapPx * (placement.itemsPerRow - 1)
|
||||
val availableWidth = parentWidthPx - parentHorizontalPaddingPx
|
||||
require(availableWidth > 0) { "available width must be > 0." }
|
||||
val width = (availableWidth - totalGap) / placement.itemsPerRow
|
||||
val height = when (placement.variant) {
|
||||
CreatorRankingCardVariant.Large,
|
||||
CreatorRankingCardVariant.Compact -> width
|
||||
CreatorRankingCardVariant.Horizontal -> (width * HORIZONTAL_FIGMA_HEIGHT) / HORIZONTAL_FIGMA_WIDTH
|
||||
}
|
||||
|
||||
return CreatorRankingCardSize(widthPx = width, heightPx = height)
|
||||
}
|
||||
}
|
||||
|
||||
data class CreatorRankingCardSize(
|
||||
val widthPx: Int,
|
||||
val heightPx: Int
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
data class CreatorRankingPlacement(
|
||||
val variant: CreatorRankingCardVariant,
|
||||
val itemsPerRow: Int
|
||||
) {
|
||||
companion object {
|
||||
fun fromRank(rank: Int): CreatorRankingPlacement {
|
||||
require(rank >= 1) { "rank must be greater than or equal to 1." }
|
||||
return when (rank) {
|
||||
1 -> CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||
in 2..7 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
|
||||
in 8..10 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
|
||||
else -> CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Shader
|
||||
import android.widget.TextView
|
||||
|
||||
internal fun TextView.applyCreatorRankingRankGradient() {
|
||||
post {
|
||||
val gradientHeight = height.takeIf { it > 0 } ?: lineHeight
|
||||
paint.shader = LinearGradient(
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
gradientHeight.toFloat(),
|
||||
Color.WHITE,
|
||||
Color.parseColor("#EEEEEE"),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_decrease.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_decrease.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 B |
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_increase.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_increase.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 B |
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_stay.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_stay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 B |
BIN
app/src/main/res/drawable-mdpi/ic_rank_new.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 802 B |
5
app/src/main/res/drawable/bg_creator_ranking_delta.xml
Normal file
5
app/src/main/res/drawable/bg_creator_ranking_delta.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/gray_900" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:startColor="#00000000"
|
||||
android:centerColor="#00000000"
|
||||
android:centerY="0.64"
|
||||
android:endColor="#80000000" />
|
||||
<corners android:radius="@dimen/radius_14" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_creator_ranking_image.xml
Normal file
5
app/src/main/res/drawable/bg_creator_ranking_image.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/gray_900" />
|
||||
<corners android:radius="@dimen/radius_14" />
|
||||
</shape>
|
||||
BIN
app/src/main/res/font/pattaya_regular.ttf
Normal file
BIN
app/src/main/res/font/pattaya_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/phosphate_solid.ttf
Normal file
BIN
app/src/main/res/font/phosphate_solid.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_creator_ranking_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_creator_ranking_image"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<View
|
||||
android:id="@+id/v_creator_ranking_dim_gradient"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_creator_ranking_dim_gradient" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_rank"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/pattaya_regular"
|
||||
android:includeFontPadding="false"
|
||||
android:shadowColor="#7A000000"
|
||||
android:shadowRadius="4"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="54sp"
|
||||
tools:text="2" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_creator_ranking_delta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_creator_ranking_delta"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_delta_amount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/medium"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
tools:text="4" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_creator_ranking_delta_icon"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:contentDescription="@null"
|
||||
tools:src="@drawable/ic_rank_caret_increase" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="@font/bold"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="22sp"
|
||||
tools:text="크리에이터 이름" />
|
||||
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_creator_ranking_rank_group"
|
||||
android:layout_width="49dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_rank"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/pattaya_regular"
|
||||
android:includeFontPadding="false"
|
||||
android:shadowColor="#7A000000"
|
||||
android:shadowRadius="4"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="40sp"
|
||||
tools:text="11" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_creator_ranking_delta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_creator_ranking_delta"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_delta_amount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/medium"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
tools:text="4" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_creator_ranking_delta_icon"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:contentDescription="@null"
|
||||
tools:src="@drawable/ic_rank_caret_increase" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_creator_ranking_image"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:background="@drawable/bg_creator_ranking_image"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="@font/bold"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
tools:text="크리에이터 이름" />
|
||||
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView>
|
||||
73
app/src/main/res/layout/view_creator_ranking_large_card.xml
Normal file
73
app/src/main/res/layout/view_creator_ranking_large_card.xml
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_creator_ranking_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_creator_ranking_image"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<View
|
||||
android:id="@+id/v_creator_ranking_dim_gradient"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_creator_ranking_dim_gradient" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_rank"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/pattaya_regular"
|
||||
android:includeFontPadding="false"
|
||||
android:shadowColor="#7A000000"
|
||||
android:shadowRadius="4"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="96sp"
|
||||
tools:text="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_creator_ranking_delta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_creator_ranking_delta"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_delta_amount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/medium"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
tools:text="4" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_creator_ranking_delta_icon"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:contentDescription="@null"
|
||||
tools:src="@drawable/ic_rank_caret_increase" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_creator_ranking_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="@font/bold"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="32sp"
|
||||
tools:text="크리에이터 이름" />
|
||||
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView>
|
||||
@@ -1308,6 +1308,7 @@ The upload will continue even if you leave this page.</string>
|
||||
<string name="audio_content_content_new_title">New shorts</string>
|
||||
<string name="audio_content_content_tag_recommend_title">Recommended content by tag</string>
|
||||
<string name="audio_content_creator_rank_title">Trending creators</string>
|
||||
<string name="creator_ranking_inaccessible_info">This information is not accessible.</string>
|
||||
<string name="audio_content_detail_age_badge_19">19</string>
|
||||
<string name="audio_content_free_channel_recommend_title">Recommended free content by channel</string>
|
||||
<string name="audio_content_free_creator_intro_title">Creator intro</string>
|
||||
|
||||
@@ -1306,6 +1306,7 @@
|
||||
<string name="audio_content_content_new_title">新しい短編</string>
|
||||
<string name="audio_content_content_tag_recommend_title">タグ別おすすめコンテンツ</string>
|
||||
<string name="audio_content_creator_rank_title">人気急上昇</string>
|
||||
<string name="creator_ranking_inaccessible_info">アクセスできない情報です。</string>
|
||||
<string name="audio_content_detail_age_badge_19">R-18</string>
|
||||
<string name="audio_content_free_channel_recommend_title">チャンネル別おすすめ無料コンテンツ</string>
|
||||
<string name="audio_content_free_creator_intro_title">クリエイター紹介</string>
|
||||
|
||||
@@ -1323,6 +1323,7 @@
|
||||
<string name="audio_content_short_play_title">숏플</string>
|
||||
<string name="audio_content_new_content_title">새로운 콘텐츠</string>
|
||||
<string name="audio_content_main_popular_notice">※ 인기 순위는 매주 업데이트됩니다.</string>
|
||||
<string name="creator_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>
|
||||
|
||||
<!-- Audio content tabs -->
|
||||
<string name="audio_content_alarm_new_title">새로운 알람</string>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CreatorRankingAdapterLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `span count supports full two and three column ranking rows`() {
|
||||
assertEquals(6, CreatorRankingAdapter.GRID_SPAN_COUNT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `span lookup maps rank groups to expected row widths`() {
|
||||
val spanLookup = CreatorRankingAdapter.createSpanSizeLookup()
|
||||
|
||||
assertEquals(6, spanLookup.getSpanSize(0))
|
||||
(1..6).forEach { position ->
|
||||
assertEquals(3, spanLookup.getSpanSize(position))
|
||||
}
|
||||
(7..9).forEach { position ->
|
||||
assertEquals(2, spanLookup.getSpanSize(position))
|
||||
}
|
||||
listOf(10, 11, 20).forEach { position ->
|
||||
assertEquals(6, spanLookup.getSpanSize(position))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import android.os.Build
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CreatorRankingBlurTest {
|
||||
|
||||
@Test
|
||||
fun `blocked image uses coil blur fallback before Android 12`() {
|
||||
assertTrue(CreatorRankingBlur.shouldUseCoilBlur(enabled = true, sdkInt = Build.VERSION_CODES.R))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked image keeps render effect path on Android 12 or later`() {
|
||||
assertFalse(CreatorRankingBlur.shouldUseCoilBlur(enabled = true, sdkInt = Build.VERSION_CODES.S))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accessible image does not use coil blur fallback`() {
|
||||
assertFalse(CreatorRankingBlur.shouldUseCoilBlur(enabled = false, sdkInt = Build.VERSION_CODES.R))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CreatorRankingDeltaPresentationTest {
|
||||
|
||||
@Test
|
||||
fun `increase shows caret and amount`() {
|
||||
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Increase, amount = 4)
|
||||
|
||||
assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
|
||||
assertTrue(presentation.showAmount)
|
||||
assertEquals("4", presentation.amountText)
|
||||
assertTrue(presentation.showPillBackground)
|
||||
assertEquals(14, presentation.iconWidthDp)
|
||||
assertEquals(14, presentation.iconHeightDp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrease shows caret and amount`() {
|
||||
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Decrease, amount = 4)
|
||||
|
||||
assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes)
|
||||
assertTrue(presentation.showAmount)
|
||||
assertEquals("4", presentation.amountText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stay shows stay icon without amount`() {
|
||||
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Stay, amount = 0)
|
||||
|
||||
assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes)
|
||||
assertFalse(presentation.showAmount)
|
||||
assertNull(presentation.amountText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new shows new image without amount`() {
|
||||
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.New, amount = 0)
|
||||
|
||||
assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
|
||||
assertFalse(presentation.showAmount)
|
||||
assertNull(presentation.amountText)
|
||||
assertFalse(presentation.showPillBackground)
|
||||
assertEquals(36, presentation.iconWidthDp)
|
||||
assertEquals(23, presentation.iconHeightDp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CreatorRankingItemTest {
|
||||
|
||||
@Test
|
||||
fun `blocked item is inaccessible`() {
|
||||
val item = sampleItem(isBlocked = true)
|
||||
|
||||
assertTrue(item.isInaccessible)
|
||||
assertFalse(item.isTouchable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accessible item is touchable`() {
|
||||
val item = sampleItem()
|
||||
|
||||
assertFalse(item.isInaccessible)
|
||||
assertTrue(item.isTouchable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `top ten blocked item hides creator name`() {
|
||||
val item = sampleItem(rank = 10, isBlocked = true)
|
||||
|
||||
assertEquals("", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rank 11 blocked item shows inaccessible message`() {
|
||||
val item = sampleItem(rank = 11, isBlocked = true)
|
||||
|
||||
assertEquals("접근할 수 없는 정보입니다.", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accessible item shows creator name`() {
|
||||
val item = sampleItem(creatorName = "크리에이터 이름")
|
||||
|
||||
assertEquals("크리에이터 이름", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||
}
|
||||
|
||||
private fun sampleItem(
|
||||
creatorId: Long = 1L,
|
||||
rank: Int = 1,
|
||||
previousRank: Int? = 5,
|
||||
rankChangeType: CreatorRankingChangeType = CreatorRankingChangeType.Increase,
|
||||
rankChangeAmount: Int = 4,
|
||||
creatorName: String = "크리에이터 이름",
|
||||
imageUrl: String = "https://example.com/image.png",
|
||||
isBlocked: Boolean = false
|
||||
) = CreatorRankingItem(
|
||||
creatorId = creatorId,
|
||||
rank = rank,
|
||||
previousRank = previousRank,
|
||||
rankChangeType = rankChangeType,
|
||||
rankChangeAmount = rankChangeAmount,
|
||||
creatorName = creatorName,
|
||||
imageUrl = imageUrl,
|
||||
isBlocked = isBlocked
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CreatorRankingLayoutCalculatorTest {
|
||||
|
||||
@Test
|
||||
fun `large card fills available width as square`() {
|
||||
val size = CreatorRankingLayoutCalculator.calculate(
|
||||
parentWidthPx = 374,
|
||||
horizontalGapPx = 4,
|
||||
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||
)
|
||||
|
||||
assertEquals(374, size.widthPx)
|
||||
assertEquals(374, size.heightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large card excludes parent horizontal padding from available width`() {
|
||||
val size = CreatorRankingLayoutCalculator.calculate(
|
||||
parentWidthPx = 374,
|
||||
parentHorizontalPaddingPx = 40,
|
||||
horizontalGapPx = 4,
|
||||
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||
)
|
||||
|
||||
assertEquals(334, size.widthPx)
|
||||
assertEquals(334, size.heightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact card can use two columns`() {
|
||||
val size = CreatorRankingLayoutCalculator.calculate(
|
||||
parentWidthPx = 374,
|
||||
horizontalGapPx = 4,
|
||||
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
|
||||
)
|
||||
|
||||
assertEquals(185, size.widthPx)
|
||||
assertEquals(185, size.heightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact card can use three columns`() {
|
||||
val size = CreatorRankingLayoutCalculator.calculate(
|
||||
parentWidthPx = 374,
|
||||
horizontalGapPx = 4,
|
||||
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
|
||||
)
|
||||
|
||||
assertEquals(122, size.widthPx)
|
||||
assertEquals(122, size.heightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `horizontal card keeps figma aspect ratio`() {
|
||||
val size = CreatorRankingLayoutCalculator.calculate(
|
||||
parentWidthPx = 374,
|
||||
horizontalGapPx = 4,
|
||||
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
|
||||
)
|
||||
|
||||
assertEquals(374, size.widthPx)
|
||||
assertEquals(100, size.heightPx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CreatorRankingPlacementTest {
|
||||
|
||||
@Test
|
||||
fun `rank 1 uses large variant and one item row`() {
|
||||
val placement = CreatorRankingPlacement.fromRank(1)
|
||||
|
||||
assertEquals(CreatorRankingCardVariant.Large, placement.variant)
|
||||
assertEquals(1, placement.itemsPerRow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rank 2 to 7 uses compact variant and two item row`() {
|
||||
(2..7).forEach { rank ->
|
||||
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||
|
||||
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
|
||||
assertEquals(2, placement.itemsPerRow)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rank 8 to 10 uses compact variant and three item row`() {
|
||||
(8..10).forEach { rank ->
|
||||
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||
|
||||
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
|
||||
assertEquals(3, placement.itemsPerRow)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rank 11 or greater uses horizontal variant and one item row`() {
|
||||
listOf(11, 12, 100).forEach { rank ->
|
||||
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||
|
||||
assertEquals(CreatorRankingCardVariant.Horizontal, placement.variant)
|
||||
assertEquals(1, placement.itemsPerRow)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `rank less than 1 is invalid`() {
|
||||
CreatorRankingPlacement.fromRank(0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user