diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt index 0cf3939f..c60a94a1 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt @@ -30,6 +30,7 @@ import kr.co.vividnext.sodalive.v2.main.home.model.RecommendedActivityType import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder +import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBusinessInfoBinder import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter @@ -69,9 +70,29 @@ class HomeMainFragment : BaseFragment( binding.textTabBarHome.root.setOnTabSelectedListener { } setUpSectionTitles() setUpRecommendationAdapters() + setUpBusinessInfo() bindHomeRecommendationContent(phase6SampleContent()) } + private fun setUpBusinessInfo() { + val businessInfoText = binding.tvHomeBusinessInfo.text + HomeBusinessInfoBinder.bind(binding.tvHomeBusinessInfo) + binding.tvHomeBusinessInfo.post { + binding.tvHomeBusinessInfo.maxLines = Int.MAX_VALUE + binding.tvHomeBusinessInfo.ellipsize = null + binding.tvHomeBusinessInfo.text = businessInfoText + binding.tvHomeBusinessInfo.post { + val totalLines = binding.tvHomeBusinessInfo.layout?.lineCount + ?: binding.tvHomeBusinessInfo.lineCount + HomeBusinessInfoBinder.updateToggleVisibility( + textView = binding.tvHomeBusinessInfo, + originalText = businessInfoText, + totalLineCount = totalLines + ) + } + } + } + private fun setUpRecommendationAdapters() { bannerBinder = HomeBannerBinder(binding.rvHomeBanners) binding.rvHomeLives.adapter = liveAdapter diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeBusinessInfoBinder.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeBusinessInfoBinder.kt new file mode 100644 index 00000000..1aec2934 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeBusinessInfoBinder.kt @@ -0,0 +1,120 @@ +package kr.co.vividnext.sodalive.v2.main.home.ui + +import android.graphics.Color +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.View +import android.widget.TextView +import kr.co.vividnext.sodalive.R + +object HomeBusinessInfoBinder { + private const val COLLAPSED_MAX_LINES = 3 + private const val ELLIPSIS = "… " + private const val ACTION_SPACING = " " + + fun bind(textView: TextView) { + textView.movementMethod = LinkMovementMethod.getInstance() + textView.highlightColor = Color.TRANSPARENT + } + + fun updateToggleVisibility( + textView: TextView, + originalText: CharSequence, + totalLineCount: Int + ) { + if (totalLineCount > COLLAPSED_MAX_LINES) { + applyCollapsed(textView, originalText) + } else { + textView.maxLines = COLLAPSED_MAX_LINES + textView.ellipsize = TextUtils.TruncateAt.END + textView.text = originalText + } + } + + private fun applyExpanded( + textView: TextView, + originalText: CharSequence + ) { + textView.maxLines = Int.MAX_VALUE + textView.ellipsize = null + textView.text = originalText.withAction( + actionText = textView.context.getString(R.string.home_recommendation_collapse), + actionSpacing = ACTION_SPACING, + onClick = { applyCollapsed(textView, originalText) } + ) + } + + private fun applyCollapsed( + textView: TextView, + originalText: CharSequence + ) { + textView.maxLines = COLLAPSED_MAX_LINES + textView.ellipsize = null + val actionText = textView.context.getString(R.string.home_recommendation_more) + val collapsedText = originalText.truncateForInlineAction(textView, actionText) + textView.text = collapsedText.withAction( + actionText = actionText, + actionSpacing = "", + onClick = { applyExpanded(textView, originalText) } + ) + } + + private fun CharSequence.truncateForInlineAction( + textView: TextView, + actionText: String + ): CharSequence { + val suffix = ELLIPSIS + actionText + val availableWidth = textView.width - textView.paddingLeft - textView.paddingRight + if (availableWidth <= 0) return this + + var endIndex = length + while (endIndex > 0) { + val candidate = take(endIndex).trimEnd().toString() + suffix + if (candidate.lineCount(textView, availableWidth) <= COLLAPSED_MAX_LINES) { + return take(endIndex).trimEnd().toString() + ELLIPSIS + } + endIndex -= 1 + } + return ELLIPSIS + } + + private fun CharSequence.withAction( + actionText: String, + actionSpacing: String, + onClick: () -> Unit + ): SpannableString { + val textWithAction = "$this$actionSpacing$actionText" + return SpannableString(textWithAction).apply { + setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) = onClick() + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } + }, + textWithAction.length - actionText.length, + textWithAction.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + private fun CharSequence.lineCount( + textView: TextView, + availableWidth: Int + ): Int { + return StaticLayout.Builder + .obtain(this, 0, length, textView.paint, availableWidth) + .setLineSpacing(textView.lineSpacingExtra, textView.lineSpacingMultiplier) + .setIncludePad(textView.includeFontPadding) + .build() + .lineCount + } +} diff --git a/app/src/main/res/layout/fragment_v2_main_home.xml b/app/src/main/res/layout/fragment_v2_main_home.xml index 39ea7ca3..5073559b 100644 --- a/app/src/main/res/layout/fragment_v2_main_home.xml +++ b/app/src/main/res/layout/fragment_v2_main_home.xml @@ -228,10 +228,12 @@ diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index 510e9167..3e20ad37 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -4,6 +4,9 @@ import android.app.Application import android.content.Context import android.content.res.Configuration import android.graphics.drawable.ColorDrawable +import android.text.Spanned +import android.text.TextUtils +import android.text.style.ClickableSpan import android.view.Gravity import android.view.LayoutInflater import android.view.View @@ -30,6 +33,7 @@ import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomePopularCommunityPo import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups import kr.co.vividnext.sodalive.v2.main.home.data.HomePopularCommunityPostItem import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter +import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBusinessInfoBinder import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowAllButtonBinder @@ -819,6 +823,88 @@ class HomeMainFragmentLayoutTest { assertNotNull(genreGroup.findViewById(R.id.tv_home_genre_creator_group_title_suffix)) } + @Test + fun `business info layout starts collapsed without separate toggle view`() { + val root = inflateView(R.layout.fragment_v2_main_home) + val businessInfoContainer = root.findViewById(R.id.ll_home_business_info) + val businessInfo = root.findViewById(R.id.tv_home_business_info) + + assertEquals(1, businessInfoContainer.childCount) + assertEquals(3, businessInfo.maxLines) + assertEquals(TextUtils.TruncateAt.END, businessInfo.ellipsize) + } + + @Test + fun `business info binder appends inline more action when content is longer than three lines`() { + val context = ApplicationProvider.getApplicationContext() + val businessInfo = TextView(context).apply { + setPadding(0, 0, 0, 0) + layout(0, 0, 180.dpToPx(), 1000.dpToPx()) + } + val originalText = "사업자 정보가 여러 줄로 길게 표시되어 마지막 줄 우측에 더보기 액션이 붙어야 합니다. 반복 텍스트입니다." + + HomeBusinessInfoBinder.bind(businessInfo) + HomeBusinessInfoBinder.updateToggleVisibility( + textView = businessInfo, + originalText = originalText, + totalLineCount = 4 + ) + + assertEquals(3, businessInfo.maxLines) + assertEquals(null, businessInfo.ellipsize) + assertFalse( + businessInfo.text.toString().contains("\n" + context.getString(R.string.home_recommendation_more)) + ) + assertEquals(true, businessInfo.text.toString().endsWith("… " + context.getString(R.string.home_recommendation_more))) + assertEquals(1, businessInfo.clickableSpanCount()) + } + + @Test + fun `business info inline action toggles collapsed and expanded state`() { + val context = ApplicationProvider.getApplicationContext() + val businessInfo = TextView(context).apply { + setPadding(0, 0, 0, 0) + layout(0, 0, 180.dpToPx(), 1000.dpToPx()) + } + val originalText = "사업자 정보가 여러 줄로 길게 표시되어 마지막 줄 우측에 더보기 액션이 붙어야 합니다. 반복 텍스트입니다." + + HomeBusinessInfoBinder.bind(businessInfo) + HomeBusinessInfoBinder.updateToggleVisibility( + textView = businessInfo, + originalText = originalText, + totalLineCount = 4 + ) + businessInfo.performLastClickableSpanClick() + + assertEquals(Int.MAX_VALUE, businessInfo.maxLines) + assertEquals(null, businessInfo.ellipsize) + assertEquals(true, businessInfo.text.toString().endsWith(" " + context.getString(R.string.home_recommendation_collapse))) + + businessInfo.performLastClickableSpanClick() + + assertEquals(3, businessInfo.maxLines) + assertEquals(null, businessInfo.ellipsize) + assertEquals(true, businessInfo.text.toString().endsWith("… " + context.getString(R.string.home_recommendation_more))) + } + + @Test + fun `business info inline action is absent when content is three lines or fewer`() { + val context = ApplicationProvider.getApplicationContext() + val businessInfo = TextView(context) + val originalText = "짧은 사업자 정보" + + HomeBusinessInfoBinder.updateToggleVisibility( + textView = businessInfo, + originalText = originalText, + totalLineCount = 3 + ) + + assertEquals(3, businessInfo.maxLines) + assertEquals(TextUtils.TruncateAt.END, businessInfo.ellipsize) + assertEquals(originalText, businessInfo.text.toString()) + assertEquals(0, businessInfo.clickableSpanCount()) + } + @Test fun `home title bar exposes right icons in cash search bell order`() { val titleBar = inflateView(R.layout.view_title_bar_home) @@ -841,6 +927,16 @@ class HomeMainFragmentLayoutTest { } } + private fun TextView.clickableSpanCount(): Int { + val spanned = text as? Spanned ?: return 0 + return spanned.getSpans(0, spanned.length, ClickableSpan::class.java).size + } + + private fun TextView.performLastClickableSpanClick() { + val spanned = text as Spanned + spanned.getSpans(0, spanned.length, ClickableSpan::class.java).last().onClick(this) + } + private fun inflateView(layoutResId: Int): View { val context = ApplicationProvider.getApplicationContext() return LayoutInflater.from(context).inflate(layoutResId, null, false)