diff --git a/README.md b/README.md index 48490f9..e64da5d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ You can [download](https://bintray.com/ericktijerou/maven/koleton/_latestVersion ```gradle // In your module's `build.gradle.kts` dependencies { - implementation("com.ericktijerou.koleton:koleton:0.8.2") + implementation("com.ericktijerou.koleton:koleton:0.8.5") } ``` @@ -38,9 +38,12 @@ repositories { To load the skeleton of a `View`, use the `loadSkeleton` extension function: ```kotlin -// Any View +// ConstraintLayout constraintLayout.loadSkeleton() +// TextView +textView?.loadSkeleton(length = 20) + // RecyclerView recyclerView.loadSkeleton(R.layout.item_example) ``` @@ -48,7 +51,7 @@ recyclerView.loadSkeleton(R.layout.item_example) Skeletons can be configured with an optional trailing lambda: ```kotlin -// Any View +// ConstraintLayout constraintLayout.loadSkeleton { color(R.color.colorSkeleton) cornerRadius(radiusInPixel) @@ -56,6 +59,12 @@ constraintLayout.loadSkeleton { lineSpacing(spacingInPixel) } +// TextView +textView?.loadSkeleton(length = 20) { + color(R.color.colorSkeleton) + ... +} + // RecyclerView recyclerView.loadSkeleton(R.layout.item_example) { itemCount(3) diff --git a/gradle.properties b/gradle.properties index fd0517f..97012be 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,4 +27,4 @@ compileSdk=29 groupId=com.ericktijerou.koleton vcsUrl=https://github.com/ericktijerou/koleton issueTrackerUrl=https://github.com/ericktijerou/koleton/issues -publishVersion=0.8.2 \ No newline at end of file +publishVersion=0.8.5 \ No newline at end of file diff --git a/koleton-base/src/main/kotlin/koleton/MainSkeletonLoader.kt b/koleton-base/src/main/kotlin/koleton/MainSkeletonLoader.kt index aa27f6a..230338c 100644 --- a/koleton-base/src/main/kotlin/koleton/MainSkeletonLoader.kt +++ b/koleton-base/src/main/kotlin/koleton/MainSkeletonLoader.kt @@ -11,9 +11,11 @@ import koleton.memory.DelegateService import koleton.memory.SkeletonService import koleton.skeleton.RecyclerViewSkeleton import koleton.skeleton.Skeleton +import koleton.skeleton.TextViewSkeleton import koleton.skeleton.ViewSkeleton import koleton.target.RecyclerViewTarget import koleton.target.SimpleViewTarget +import koleton.target.TextViewTarget import koleton.target.ViewTarget import koleton.util.* import kotlinx.coroutines.* @@ -90,6 +92,24 @@ internal class MainSkeletonLoader( return when (skeleton) { is RecyclerViewSkeleton -> generateRecyclerView(skeleton) is ViewSkeleton -> generateSimpleView(skeleton) + is TextViewSkeleton -> generateTextView(skeleton) + } + } + + private fun generateTextView(skeleton: TextViewSkeleton) = with(skeleton) { + return@with if (target is TextViewTarget) { + val attributes = TextViewAttributes( + view = target.view, + color = context.getColorCompat(colorResId ?: defaults.colorResId), + cornerRadius = cornerRadius ?: defaults.cornerRadius, + isShimmerEnabled = isShimmerEnabled ?: defaults.isShimmerEnabled, + shimmer = shimmer ?: defaults.shimmer, + lineSpacing = lineSpacing ?: defaults.lineSpacing, + length = length + ) + target.view.generateTextKoletonView(attributes) + } else { + TextKoletonView(context) } } diff --git a/koleton-base/src/main/kotlin/koleton/custom/Attributes.kt b/koleton-base/src/main/kotlin/koleton/custom/Attributes.kt index 3772b08..72d2801 100644 --- a/koleton-base/src/main/kotlin/koleton/custom/Attributes.kt +++ b/koleton-base/src/main/kotlin/koleton/custom/Attributes.kt @@ -1,5 +1,6 @@ package koleton.custom +import android.widget.TextView import androidx.annotation.ColorInt import androidx.annotation.LayoutRes import androidx.annotation.Px @@ -31,4 +32,14 @@ data class SimpleViewAttributes( override val isShimmerEnabled: Boolean, override val shimmer: Shimmer, override val lineSpacing: Float -): Attributes() \ No newline at end of file +): Attributes() + +data class TextViewAttributes( + val view: TextView, + @ColorInt override val color: Int, + @Px override val cornerRadius: Float, + override val isShimmerEnabled: Boolean, + override val shimmer: Shimmer, + override val lineSpacing: Float, + val length: Int +): Attributes() diff --git a/koleton-base/src/main/kotlin/koleton/custom/TextKoletonView.kt b/koleton-base/src/main/kotlin/koleton/custom/TextKoletonView.kt new file mode 100644 index 0000000..ae776be --- /dev/null +++ b/koleton-base/src/main/kotlin/koleton/custom/TextKoletonView.kt @@ -0,0 +1,100 @@ +package koleton.custom + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import koleton.mask.KoletonMask +import koleton.util.* + +internal class TextKoletonView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : KoletonView(context, attrs, defStyleAttr) { + + private var koletonMask: KoletonMask? = null + override var isSkeletonShown: Boolean = false + private var isMeasured: Boolean = false + private val viewList = arrayListOf() + + var attributes: TextViewAttributes? = null + set(value) { + field = value + originalText = value?.view?.text.toString() + value?.let { applyAttributes() } + } + + private var originalText: String = EMPTY_STRING + + private fun hideVisibleChildren(view: View) { + when (view) { + is ViewGroup -> view.children().forEach { hideVisibleChildren(it) } + else -> hideVisibleChild(view) + } + } + + private fun hideVisibleChild(view: View) { + if (view.isVisible()) { + view.invisible() + viewList.add(view) + } + } + + override fun showSkeleton() { + isSkeletonShown = true + setFakeText() + if (isMeasured && childCount > 0) { + hideVisibleChildren(this) + applyAttributes() + } + } + + override fun hideSkeleton() { + isSkeletonShown = false + restoreOriginalText() + if (childCount > 0) { + viewList.forEach { it.visible() } + hideShimmer() + koletonMask = null + } + } + + private fun setFakeText() { + attributes?.apply { + view.text = getRandomAlphaNumericString(length) + } + } + + private fun restoreOriginalText() { + attributes?.apply { + view.text = originalText + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + isMeasured = width > NUMBER_ZERO && height > NUMBER_ZERO + if (isSkeletonShown) { + showSkeleton() + } + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + canvas?.let { koletonMask?.draw(it) } + } + + override fun applyAttributes() { + if (isMeasured) { + attributes?.let { attrs -> + if (!attrs.isShimmerEnabled) { + hideShimmer() + } else { + setShimmer(attrs.shimmer) + } + koletonMask = KoletonMask(this, attrs.color, attrs.cornerRadius, attrs.lineSpacing) + } + } + } + +} \ No newline at end of file diff --git a/koleton-base/src/main/kotlin/koleton/mask/KoletonMask.kt b/koleton-base/src/main/kotlin/koleton/mask/KoletonMask.kt index c890ca0..878e530 100644 --- a/koleton-base/src/main/kotlin/koleton/mask/KoletonMask.kt +++ b/koleton-base/src/main/kotlin/koleton/mask/KoletonMask.kt @@ -1,8 +1,8 @@ package koleton.mask import android.graphics.* +import android.graphics.text.LineBreaker import android.os.Build -import android.text.Layout import android.text.StaticLayout import android.text.TextPaint import android.view.View @@ -78,7 +78,7 @@ internal class KoletonMask( val spannable = spannable { background(color, cornerRadius, lineSpacingPerLine, view.text) } val staticLayout = StaticLayout.Builder .obtain(spannable, 0, spannable.length, textPaint.apply { color = Color.TRANSPARENT }, view.width) - .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE) + .setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE) .setIncludePad(view.includeFontPadding) .setMaxLines(view.lineCount) .build() diff --git a/koleton-base/src/main/kotlin/koleton/memory/DelegateService.kt b/koleton-base/src/main/kotlin/koleton/memory/DelegateService.kt index 3881165..b1da9f0 100644 --- a/koleton-base/src/main/kotlin/koleton/memory/DelegateService.kt +++ b/koleton-base/src/main/kotlin/koleton/memory/DelegateService.kt @@ -6,6 +6,7 @@ import koleton.SkeletonLoader import koleton.annotation.ExperimentalKoletonApi import koleton.skeleton.RecyclerViewSkeleton import koleton.skeleton.Skeleton +import koleton.skeleton.TextViewSkeleton import koleton.skeleton.ViewSkeleton import koleton.target.ViewTarget import koleton.util.koletonManager @@ -33,7 +34,7 @@ internal class DelegateService( ): SkeletonDelegate? { val skeletonDelegate: SkeletonDelegate when (skeleton) { - is ViewSkeleton, is RecyclerViewSkeleton -> when (val target = skeleton.target) { + is ViewSkeleton, is RecyclerViewSkeleton, is TextViewSkeleton -> when (val target = skeleton.target) { is ViewTarget<*> -> { skeletonDelegate = ViewTargetSkeletonDelegate( imageLoader = imageLoader, diff --git a/koleton-base/src/main/kotlin/koleton/memory/SkeletonService.kt b/koleton-base/src/main/kotlin/koleton/memory/SkeletonService.kt index 8d15dea..07ba930 100644 --- a/koleton-base/src/main/kotlin/koleton/memory/SkeletonService.kt +++ b/koleton-base/src/main/kotlin/koleton/memory/SkeletonService.kt @@ -6,6 +6,7 @@ import koleton.lifecycle.GlobalLifecycle import koleton.lifecycle.LifecycleCoroutineDispatcher import koleton.skeleton.RecyclerViewSkeleton import koleton.skeleton.Skeleton +import koleton.skeleton.TextViewSkeleton import koleton.skeleton.ViewSkeleton import koleton.target.Target import koleton.target.ViewTarget @@ -19,7 +20,7 @@ internal class SkeletonService { @MainThread fun lifecycleInfo(skeleton: Skeleton): LifecycleInfo { when (skeleton) { - is ViewSkeleton, is RecyclerViewSkeleton -> { + is ViewSkeleton, is RecyclerViewSkeleton, is TextViewSkeleton -> { val lifecycle = skeleton.getLifecycle() return if (lifecycle != null) { val mainDispatcher = LifecycleCoroutineDispatcher diff --git a/koleton-base/src/main/kotlin/koleton/skeleton/Skeleton.kt b/koleton-base/src/main/kotlin/koleton/skeleton/Skeleton.kt index a5dcfe8..194a29c 100644 --- a/koleton-base/src/main/kotlin/koleton/skeleton/Skeleton.kt +++ b/koleton-base/src/main/kotlin/koleton/skeleton/Skeleton.kt @@ -4,6 +4,7 @@ package koleton.skeleton import android.content.Context import android.view.View +import android.widget.TextView import androidx.annotation.ColorRes import androidx.annotation.LayoutRes import androidx.annotation.MainThread @@ -15,6 +16,7 @@ import koleton.custom.KoletonView import koleton.target.RecyclerViewTarget import koleton.target.SimpleViewTarget import koleton.target.Target +import koleton.target.TextViewTarget /** * The base class for a skeleton view. @@ -243,4 +245,89 @@ class RecyclerViewSkeleton internal constructor( ) } } +} + +class TextViewSkeleton internal constructor( + override val context: Context, + override val target: Target?, + override val lifecycle: Lifecycle?, + @ColorRes override val colorResId: Int?, + @Px override val cornerRadius: Float?, + override val isShimmerEnabled: Boolean?, + override val shimmer: Shimmer?, + override val lineSpacing: Float?, + internal val length: Int +) : Skeleton() { + + /** Create a new [Builder] instance using this as a base. */ + @JvmOverloads + fun newBuilder(context: Context = this.context) = Builder(this, context) + + class Builder : SkeletonBuilder { + + private var target: Target? + private var lifecycle: Lifecycle? + private var length: Int + + constructor(context: Context, length: Int) : super(context) { + target = null + lifecycle = null + this.length = length + } + + @JvmOverloads + constructor( + skeleton: TextViewSkeleton, + context: Context = skeleton.context + ) : super(skeleton, context) { + target = skeleton.target + lifecycle = skeleton.lifecycle + length = skeleton.length + } + + /** + * Convenience function to set [view] as the [Target]. + */ + fun target(view: TextView) = apply { + target(TextViewTarget(view)) + } + + /** + * Convenience function to create and set the [Target]. + */ + inline fun target( + crossinline onStart: () -> Unit = {}, + crossinline onError: () -> Unit = {}, + crossinline onSuccess: (skeleton: KoletonView) -> Unit = {} + ) = target(object : Target { + override fun onStart() = onStart() + override fun onError() = onError() + override fun onSuccess(skeleton: KoletonView) = onSuccess(skeleton) + }) + + fun target(target: Target?) = apply { + this.target = target + } + + fun lifecycle(lifecycle: Lifecycle?) = apply { + this.lifecycle = lifecycle + } + + /** + * Create a new [ViewSkeleton] instance. + */ + fun build(): TextViewSkeleton { + return TextViewSkeleton( + context, + target, + lifecycle, + colorResId, + cornerRadius, + isShimmerEnabled, + shimmer, + lineSpacing, + length + ) + } + } } \ No newline at end of file diff --git a/koleton-base/src/main/kotlin/koleton/target/TextViewTarget.kt b/koleton-base/src/main/kotlin/koleton/target/TextViewTarget.kt new file mode 100644 index 0000000..06f310b --- /dev/null +++ b/koleton-base/src/main/kotlin/koleton/target/TextViewTarget.kt @@ -0,0 +1,21 @@ +package koleton.target + +import android.widget.TextView +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import koleton.custom.KoletonView + +/** A [Target] that handles setting skeleton on a [TextView]. */ +open class TextViewTarget(override val view: TextView) : ViewTarget, DefaultLifecycleObserver { + + override fun onStart() = Unit + + /** Show the [skeleton] view */ + override fun onSuccess(skeleton: KoletonView) = skeleton.showSkeleton() + + override fun onError() = Unit + + override fun onStart(owner: LifecycleOwner) {} + + override fun onStop(owner: LifecycleOwner) {} +} \ No newline at end of file diff --git a/koleton-base/src/main/kotlin/koleton/util/Commons.kt b/koleton-base/src/main/kotlin/koleton/util/Extensions.kt similarity index 100% rename from koleton-base/src/main/kotlin/koleton/util/Commons.kt rename to koleton-base/src/main/kotlin/koleton/util/Extensions.kt diff --git a/koleton-base/src/main/kotlin/koleton/util/RandomUtils.kt b/koleton-base/src/main/kotlin/koleton/util/RandomUtils.kt new file mode 100644 index 0000000..d7b6a7b --- /dev/null +++ b/koleton-base/src/main/kotlin/koleton/util/RandomUtils.kt @@ -0,0 +1,9 @@ +package koleton.util + +private const val RANDOM_ALPHANUMERIC_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz0123456789" + +internal fun getRandomAlphaNumericString(length: Int): String { + return (1..length) + .map { RANDOM_ALPHANUMERIC_CHARS.random() } + .joinToString(EMPTY_STRING) +} diff --git a/koleton-base/src/main/kotlin/koleton/util/Views.kt b/koleton-base/src/main/kotlin/koleton/util/Views.kt index 7caecf6..f4d932e 100644 --- a/koleton-base/src/main/kotlin/koleton/util/Views.kt +++ b/koleton-base/src/main/kotlin/koleton/util/Views.kt @@ -4,17 +4,20 @@ import android.graphics.Color import android.os.Build import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewParent import android.view.ViewTreeObserver import android.widget.FrameLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView import koleton.base.R -import koleton.custom.Attributes +import koleton.custom.* import koleton.custom.RecyclerKoletonView -import koleton.custom.RecyclerViewAttributes import koleton.custom.SimpleKoletonView +import koleton.custom.TextKoletonView import koleton.memory.ViewTargetSkeletonManager internal fun View.visible() { @@ -78,10 +81,30 @@ internal fun RecyclerView.generateRecyclerKoletonView(attributes: RecyclerViewAt } } +internal fun TextView.generateTextKoletonView(attributes: TextViewAttributes): TextKoletonView { + val parent = parent as? ViewGroup + return TextKoletonView(context).also { + it.id = id + it.layoutParams = layoutParams + it.cloneTranslations(this) + parent?.removeView(this) + ViewCompat.setLayoutDirection(it, ViewCompat.getLayoutDirection(this)) + it.addView(this.lparams(layoutParams)) + parent?.addView(it) + it.attributes = attributes + } +} + internal fun T.lparams(source: ViewGroup.LayoutParams): T { val layoutParams = FrameLayout.LayoutParams(source).apply { - if (width.isZero()) width = this@lparams.width - if (height.isZero()) height = this@lparams.height + if (width.isZero()) { + width = if (this@lparams.width.isZero() && source is ConstraintLayout.LayoutParams) MATCH_PARENT + else this@lparams.width + } + if (height.isZero()) { + height = if (this@lparams.height.isZero() && source is ConstraintLayout.LayoutParams) MATCH_PARENT + else this@lparams.height + } } this@lparams.layoutParams = layoutParams return this diff --git a/koleton-sample/src/main/kotlin/koleton/sample/list/JourneyListFragment.kt b/koleton-sample/src/main/kotlin/koleton/sample/list/JourneyListFragment.kt index 3f7f93f..56eeedf 100644 --- a/koleton-sample/src/main/kotlin/koleton/sample/list/JourneyListFragment.kt +++ b/koleton-sample/src/main/kotlin/koleton/sample/list/JourneyListFragment.kt @@ -76,11 +76,14 @@ class JourneyListFragment : Fragment() { private fun onLoadInitial() { ivRefresh?.gone() rvUsers?.loadSkeleton(R.layout.item_journey) + tvSubtitle?.loadSkeleton(length = 20) } private fun onLoaded() { ivRefresh?.visible() rvUsers?.hideSkeleton() + tvSubtitle?.hideSkeleton() + tvSubtitle?.text = requireContext().getString(R.string.label_see_your_journey) } private fun onRefreshClickListener() { diff --git a/koleton-sample/src/main/kotlin/koleton/sample/utils/Constants.kt b/koleton-sample/src/main/kotlin/koleton/sample/utils/Constants.kt new file mode 100644 index 0000000..77284e5 --- /dev/null +++ b/koleton-sample/src/main/kotlin/koleton/sample/utils/Constants.kt @@ -0,0 +1,5 @@ +package koleton.sample.utils + +const val DEFAULT_DELAY: Long = 3000 +const val DEFAULT_PAGE_SIZE: Int = 10 +const val ITEM_COUNT: Int = 3 \ No newline at end of file diff --git a/koleton-sample/src/main/kotlin/koleton/sample/utils/Commons.kt b/koleton-sample/src/main/kotlin/koleton/sample/utils/Extensions.kt similarity index 85% rename from koleton-sample/src/main/kotlin/koleton/sample/utils/Commons.kt rename to koleton-sample/src/main/kotlin/koleton/sample/utils/Extensions.kt index e73bac5..deadcf5 100644 --- a/koleton-sample/src/main/kotlin/koleton/sample/utils/Commons.kt +++ b/koleton-sample/src/main/kotlin/koleton/sample/utils/Extensions.kt @@ -25,8 +25,4 @@ fun Context.getDimension(@DimenRes resId: Int): Float { fun getViewModelFactory(): ViewModelFactory { val executor = Executors.newFixedThreadPool(5) return ViewModelFactory(JourneyRepository(executor)) -} - -const val DEFAULT_DELAY: Long = 3000 -const val DEFAULT_PAGE_SIZE: Int = 10 -const val ITEM_COUNT: Int = 3 \ No newline at end of file +} \ No newline at end of file diff --git a/koleton-sample/src/main/res/layout/fragment_journey_list.xml b/koleton-sample/src/main/res/layout/fragment_journey_list.xml index 608c0e5..dfcf568 100644 --- a/koleton-sample/src/main/res/layout/fragment_journey_list.xml +++ b/koleton-sample/src/main/res/layout/fragment_journey_list.xml @@ -52,12 +52,14 @@ diff --git a/koleton-singleton/src/main/kotlin/koleton/api/Views.kt b/koleton-singleton/src/main/kotlin/koleton/api/Views.kt index 3381a8b..69f9d8d 100644 --- a/koleton-singleton/src/main/kotlin/koleton/api/Views.kt +++ b/koleton-singleton/src/main/kotlin/koleton/api/Views.kt @@ -5,6 +5,7 @@ package koleton.api import android.view.View +import android.widget.TextView import androidx.annotation.LayoutRes import androidx.recyclerview.widget.RecyclerView import koleton.Koleton @@ -12,6 +13,7 @@ import koleton.SkeletonLoader import koleton.annotation.ExperimentalKoletonApi import koleton.custom.KoletonView import koleton.skeleton.RecyclerViewSkeleton +import koleton.skeleton.TextViewSkeleton import koleton.skeleton.ViewSkeleton import koleton.util.KoletonUtils @@ -96,6 +98,35 @@ inline fun RecyclerView.loadSkeleton( skeletonLoader.load(skeleton) } +/** + * Set [length] of the [TextView]. + * + * This is the type-unsafe version of [TextView.loadSkeleton]. + * + * Example: + * ``` + * textView.loadSkeleton(10) { + * color(R.color.colorExample) + * } + * ``` + * @param length Length of the [TextView]. + * @param skeletonLoader The [SkeletonLoader] that will be used to create the [TextViewSkeleton]. + * @param builder An optional lambda to configure the skeleton before it is loaded. + */ +@JvmSynthetic +inline fun TextView.loadSkeleton( + length: Int, + skeletonLoader: SkeletonLoader = Koleton.skeletonLoader(context), + builder: TextViewSkeleton.Builder.() -> Unit = {} +) { + val skeleton = TextViewSkeleton.Builder(context, length) + .target(this) + .apply(builder) + .build() + skeletonLoader.load(skeleton) +} + + /** * @return True if the skeleton associated with this [View] is shown. */