feat(widget): 크리에이터 랭킹 위젯을 추가한다

This commit is contained in:
2026-05-20 10:41:07 +09:00
parent 6fda122091
commit 01fea58e4c
35 changed files with 2341 additions and 0 deletions

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingCardVariant {
Large,
Compact,
Horizontal
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingChangeType {
Increase,
Decrease,
Stay,
New
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

View 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>

View File

@@ -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>

View 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>

Binary file not shown.

Binary file not shown.

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>