From f32f1eeaa547dbf3a7a6d0069ee6332d8a440fe7 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Sat, 9 Oct 2021 22:02:45 +0700 Subject: [PATCH] Manga description adjustments (#6011) * Manga description adjustments - Animated state changes - Adjust scrim position to fully show 2 lines when shrunk - Set minLines to avoid scrim hiding oneliner * Change icon and adjust animation * Revert fancy scrim animation --- .../ui/manga/info/MangaInfoHeaderAdapter.kt | 137 +++---------- .../tachiyomi/widget/MangaSummaryView.kt | 188 ++++++++++++++++++ app/src/main/res/drawable/anim_caret_down.xml | 85 ++++++++ app/src/main/res/drawable/anim_caret_up.xml | 84 ++++++++ .../res/layout-sw720dp/manga_info_header.xml | 120 +---------- app/src/main/res/layout/manga_info_header.xml | 116 +---------- app/src/main/res/layout/manga_summary.xml | 94 +++++++++ 7 files changed, 480 insertions(+), 344 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt create mode 100644 app/src/main/res/drawable/anim_caret_down.xml create mode 100644 app/src/main/res/drawable/anim_caret_up.xml create mode 100644 app/src/main/res/layout/manga_summary.xml diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt index 5dff9c637..ca47583d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt @@ -20,9 +20,7 @@ import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.view.loadAnyAutoPause -import eu.kanade.tachiyomi.util.view.setChips import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import reactivecircus.flowbinding.android.view.clicks import reactivecircus.flowbinding.android.view.longClicks @@ -45,13 +43,14 @@ class MangaInfoHeaderAdapter( private lateinit var binding: MangaInfoHeaderBinding - private var initialLoad: Boolean = true - - private val maxLines = 3 - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) updateCoverPosition() + + // Expand manga info if navigated from source listing or explicitly set to + // (e.g. on tablets) + binding.mangaSummarySection.expanded = fromSource || isTablet + return HeaderViewHolder(binding.root) } @@ -180,15 +179,6 @@ class MangaInfoHeaderAdapter( } .launchIn(controller.viewScope) - binding.mangaSummaryText.longClicks() - .onEach { - controller.activity?.copyToClipboard( - view.context.getString(R.string.description), - binding.mangaSummaryText.text.toString() - ) - } - .launchIn(controller.viewScope) - binding.mangaCover.clicks() .onEach { controller.showFullCoverDialog() @@ -201,7 +191,7 @@ class MangaInfoHeaderAdapter( } .launchIn(controller.viewScope) - setMangaInfo(manga, source) + setMangaInfo() } private fun showCoverOptionsDialog() { @@ -231,7 +221,7 @@ class MangaInfoHeaderAdapter( * @param manga manga object containing information about manga. * @param source the source of the manga. */ - private fun setMangaInfo(manga: Manga, source: Source?) { + private fun setMangaInfo() { // Update full title TextView. binding.mangaFullTitle.text = if (manga.title.isBlank()) { view.context.getString(R.string.unknown) @@ -254,27 +244,23 @@ class MangaInfoHeaderAdapter( } // If manga source is known update source TextView. - val mangaSource = source?.toString() + val mangaSource = source.toString() with(binding.mangaSource) { - if (mangaSource != null) { - val enabledLanguages = preferences.enabledLanguages().get() - .filterNot { it in listOf("all", "other") } + val enabledLanguages = preferences.enabledLanguages().get() + .filterNot { it in listOf("all", "other") } - val hasOneActiveLanguages = enabledLanguages.size == 1 - val isInEnabledLanguages = source.lang in enabledLanguages - text = when { - // For edge cases where user disables a source they got manga of in their library. - hasOneActiveLanguages && !isInEnabledLanguages -> mangaSource - // Hide the language tag when only one language is used. - hasOneActiveLanguages && isInEnabledLanguages -> source.name - else -> mangaSource - } + val hasOneActiveLanguages = enabledLanguages.size == 1 + val isInEnabledLanguages = source.lang in enabledLanguages + text = when { + // For edge cases where user disables a source they got manga of in their library. + hasOneActiveLanguages && !isInEnabledLanguages -> mangaSource + // Hide the language tag when only one language is used. + hasOneActiveLanguages && isInEnabledLanguages -> source.name + else -> mangaSource + } - setOnClickListener { - controller.performSearch(sourceManager.getOrStub(source.id).name) - } - } else { - text = view.context.getString(R.string.unknown) + setOnClickListener { + controller.performSearch(sourceManager.getOrStub(source.id).name) } } @@ -296,84 +282,9 @@ class MangaInfoHeaderAdapter( binding.mangaCover.loadAnyAutoPause(manga) // Manga info section - val hasInfoContent = !manga.description.isNullOrBlank() || !manga.genre.isNullOrBlank() - showMangaInfo(hasInfoContent) - if (hasInfoContent) { - // Update description TextView. - binding.mangaSummaryText.text = updateDescription(manga.description, (fromSource || isTablet).not()) - - // Update genres list - if (!manga.genre.isNullOrBlank()) { - binding.mangaGenresTagsCompactChips.setChips( - manga.getGenres(), - controller::performGenreSearch - ) - binding.mangaGenresTagsFullChips.setChips( - manga.getGenres(), - controller::performGenreSearch - ) - } else { - binding.mangaGenresTagsCompact.isVisible = false - binding.mangaGenresTagsCompactChips.isVisible = false - binding.mangaGenresTagsFullChips.isVisible = false - } - - // Handle showing more or less info - merge( - binding.mangaSummaryText.clicks(), - binding.mangaInfoToggleMore.clicks(), - binding.mangaInfoToggleLess.clicks(), - binding.mangaSummarySection.clicks(), - ) - .onEach { toggleMangaInfo() } - .launchIn(controller.viewScope) - - if (initialLoad) { - binding.mangaGenresTagsCompact.requestLayout() - } - - // Expand manga info if navigated from source listing or explicitly set to - // (e.g. on tablets) - if (initialLoad && (fromSource || isTablet)) { - toggleMangaInfo() - initialLoad = false - } - } - } - - private fun showMangaInfo(visible: Boolean) { - binding.mangaSummarySection.isVisible = visible - } - - private fun toggleMangaInfo() { - val isCurrentlyExpanded = binding.mangaSummaryText.maxLines != maxLines - - binding.mangaInfoToggleMore.isVisible = isCurrentlyExpanded - binding.mangaInfoScrim.isVisible = isCurrentlyExpanded - binding.mangaInfoToggleMoreScrim.isVisible = isCurrentlyExpanded - binding.mangaGenresTagsCompact.isVisible = isCurrentlyExpanded - binding.mangaGenresTagsCompactChips.isVisible = isCurrentlyExpanded - - binding.mangaInfoToggleLess.isVisible = !isCurrentlyExpanded - binding.mangaGenresTagsFullChips.isVisible = !isCurrentlyExpanded - - binding.mangaSummaryText.text = updateDescription(manga.description, isCurrentlyExpanded) - - binding.mangaSummaryText.maxLines = when { - isCurrentlyExpanded -> maxLines - else -> Int.MAX_VALUE - } - } - - private fun updateDescription(description: String?, isCurrentlyExpanded: Boolean): CharSequence { - return when { - description.isNullOrBlank() -> view.context.getString(R.string.unknown) - isCurrentlyExpanded -> - description - .replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "") - .replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n") - else -> description - } + binding.mangaSummarySection.isVisible = !manga.description.isNullOrBlank() || !manga.genre.isNullOrBlank() + binding.mangaSummarySection.description = manga.description + binding.mangaSummarySection.setTags(manga.getGenres(), controller::performGenreSearch) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt new file mode 100644 index 000000000..d16b814d5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/MangaSummaryView.kt @@ -0,0 +1,188 @@ +package eu.kanade.tachiyomi.widget + +import android.animation.AnimatorSet +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.drawable.Animatable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.annotation.AttrRes +import androidx.annotation.StyleRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.doOnNextLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.MangaSummaryBinding +import eu.kanade.tachiyomi.util.system.animatorDurationScale +import eu.kanade.tachiyomi.util.system.copyToClipboard +import eu.kanade.tachiyomi.util.view.setChips +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +class MangaSummaryView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, + @StyleRes defStyleRes: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) { + + private val binding = MangaSummaryBinding.inflate(LayoutInflater.from(context), this, true) + + private var animatorSet: AnimatorSet? = null + + private var recalculateHeights = false + private var descExpandedHeight = -1 + private var descShrunkHeight = -1 + + var expanded = false + set(value) { + if (field != value) { + field = value + updateExpandState() + } + } + + var description: CharSequence? = null + set(value) { + if (field != value) { + field = if (value.isNullOrBlank()) { + context.getString(R.string.unknown) + } else { + value + } + binding.descriptionText.text = field + recalculateHeights = true + doOnNextLayout { + updateExpandState() + } + requestLayout() + } + } + + fun setTags(items: List?, onClick: (item: String) -> Unit) { + binding.tagChipsShrunk.setChips(items, onClick) + binding.tagChipsExpanded.setChips(items, onClick) + } + + private fun updateExpandState() = binding.apply { + val initialSetup = descriptionText.maxHeight < 0 + + val maxHeightTarget = if (expanded) descExpandedHeight else descShrunkHeight + val maxHeightStart = if (initialSetup) maxHeightTarget else descriptionText.maxHeight + val descMaxHeightAnimator = ValueAnimator().apply { + setIntValues(maxHeightStart, maxHeightTarget) + addUpdateListener { + descriptionText.maxHeight = it.animatedValue as Int + } + } + + val toggleDrawable = ContextCompat.getDrawable( + context, + if (expanded) R.drawable.anim_caret_up else R.drawable.anim_caret_down + ) + toggleMore.setImageDrawable(toggleDrawable) + + var pastHalf = false + val toggleTarget = if (expanded) 1F else 0F + val toggleStart = if (initialSetup) { + toggleTarget + } else { + toggleMore.translationY / toggleMore.height + } + val toggleAnimator = ValueAnimator().apply { + setFloatValues(toggleStart, toggleTarget) + addUpdateListener { + val value = it.animatedValue as Float + + toggleMore.translationY = toggleMore.height * value + descriptionScrim.translationY = toggleMore.translationY + toggleMoreScrim.translationY = toggleMore.translationY + tagChipsShrunkContainer.updateLayoutParams { + topMargin = toggleMore.translationY.roundToInt() + } + tagChipsExpanded.updateLayoutParams { + topMargin = toggleMore.translationY.roundToInt() + } + + // Update non-animatable objects mid-animation makes it feel less abrupt + if (it.animatedFraction >= 0.5F && !pastHalf) { + pastHalf = true + descriptionText.text = trimWhenNeeded(description) + tagChipsShrunkContainer.scrollX = 0 + tagChipsShrunkContainer.isVisible = !expanded + tagChipsExpanded.isVisible = expanded + } + } + } + + animatorSet?.cancel() + animatorSet = AnimatorSet().apply { + interpolator = FastOutSlowInInterpolator() + duration = (TOGGLE_ANIM_DURATION * context.animatorDurationScale).roundToLong() + playTogether(toggleAnimator, descMaxHeightAnimator) + start() + } + (toggleDrawable as? Animatable)?.start() + } + + private fun trimWhenNeeded(text: CharSequence?): CharSequence? { + return if (!expanded) { + text + ?.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "") + ?.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n") + } else { + text + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (!recalculateHeights) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + recalculateHeights = false + + // Measure with expanded lines + binding.descriptionText.maxLines = Int.MAX_VALUE + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + descExpandedHeight = binding.descriptionText.measuredHeight + + // Measure with shrunk lines + binding.descriptionText.maxLines = SHRUNK_DESC_MAX_LINES + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + descShrunkHeight = binding.descriptionText.measuredHeight + } + + init { + binding.descriptionText.apply { + // So that 1 line of text won't be hidden by scrim + minLines = DESC_MIN_LINES + + setOnLongClickListener { + context.copyToClipboard( + context.getString(R.string.description), + text.toString() + ) + true + } + } + + arrayOf( + binding.descriptionText, + binding.descriptionScrim, + binding.toggleMoreScrim, + binding.toggleMore + ).forEach { + it.setOnClickListener { expanded = !expanded } + } + } +} + +private const val TOGGLE_ANIM_DURATION = 300L + +private const val DESC_MIN_LINES = 2 +private const val SHRUNK_DESC_MAX_LINES = 3 diff --git a/app/src/main/res/drawable/anim_caret_down.xml b/app/src/main/res/drawable/anim_caret_down.xml new file mode 100644 index 000000000..e5288406e --- /dev/null +++ b/app/src/main/res/drawable/anim_caret_down.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_caret_up.xml b/app/src/main/res/drawable/anim_caret_up.xml new file mode 100644 index 000000000..78b817a16 --- /dev/null +++ b/app/src/main/res/drawable/anim_caret_up.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-sw720dp/manga_info_header.xml b/app/src/main/res/layout-sw720dp/manga_info_header.xml index bff6893b1..c76ab43d8 100644 --- a/app/src/main/res/layout-sw720dp/manga_info_header.xml +++ b/app/src/main/res/layout-sw720dp/manga_info_header.xml @@ -188,126 +188,12 @@ - - - - - - - - - - - - - - - - - - - - - - + app:layout_constraintTop_toBottomOf="@id/manga_actions" /> diff --git a/app/src/main/res/layout/manga_info_header.xml b/app/src/main/res/layout/manga_info_header.xml index 63f3f62c7..e241c006e 100644 --- a/app/src/main/res/layout/manga_info_header.xml +++ b/app/src/main/res/layout/manga_info_header.xml @@ -200,124 +200,12 @@ - - - - - - - - - - - - - - - - - - - - - + app:layout_constraintTop_toBottomOf="@id/manga_actions" /> diff --git a/app/src/main/res/layout/manga_summary.xml b/app/src/main/res/layout/manga_summary.xml new file mode 100644 index 000000000..0559e8a6e --- /dev/null +++ b/app/src/main/res/layout/manga_summary.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + +