From 32738dfdfa26add1e68b3a1151433652f827f657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yamal=20C=C3=A9sar=20Al-Mahamid=20V=C3=A9lez?= Date: Fri, 27 Oct 2023 14:27:34 +0200 Subject: [PATCH] New Snackbar spec (#306) * ANDROID-13817 Replace snackbar implementaion with custom inflated layout * ANDROID-13817 Place action in another line if it is too long * ANDROID-13817 Dismiss action * ANDROID-13817 Show dismiss button based on params * ANDROID-13817 fix condition * update readme * ANDROID-13817 Change dismiss button logic * ANDROID-13817 Receive correct events on snackbar callback * ANDROID-13817 Refactor catalog ui --- .../components/SnackBarCatalogFragment.kt | 30 +++-- .../res/layout/screen_snackbar_catalog.xml | 44 +++---- .../com/telefonica/mistica/feedback/README.md | 25 +++- .../mistica/feedback/SnackbarBuilder.kt | 116 ++++++++++++++---- .../feedback/snackbar/CustomSnackbarLayout.kt | 95 ++++++++++++++ .../layout/snackbar_custom_layout_merge.xml | 77 ++++++++++++ .../src/main/res/layout/snackbar_layout.xml | 7 ++ .../src/main/res/values/dimens_snackbar.xml | 8 ++ 8 files changed, 336 insertions(+), 66 deletions(-) create mode 100644 library/src/main/java/com/telefonica/mistica/feedback/snackbar/CustomSnackbarLayout.kt create mode 100644 library/src/main/res/layout/snackbar_custom_layout_merge.xml create mode 100644 library/src/main/res/layout/snackbar_layout.xml create mode 100644 library/src/main/res/values/dimens_snackbar.xml diff --git a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/SnackBarCatalogFragment.kt b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/SnackBarCatalogFragment.kt index 71e9d784d..5f78c5b9f 100644 --- a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/SnackBarCatalogFragment.kt +++ b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/SnackBarCatalogFragment.kt @@ -7,11 +7,11 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.Button -import android.widget.RadioButton import androidx.fragment.app.Fragment import com.telefonica.mistica.catalog.R import com.telefonica.mistica.feedback.SnackbarBuilder import com.telefonica.mistica.feedback.SnackbarLength +import com.telefonica.mistica.input.CheckBoxInput import com.telefonica.mistica.input.DropDownInput import com.telefonica.mistica.input.TextInput @@ -33,7 +33,8 @@ class SnackBarCatalogFragment : Fragment() { val inputAction: TextInput = view.findViewById(R.id.input_snackbar_action) val dropDownInput: DropDownInput = view.findViewById(R.id.dropdown_snackbar_type) val createButton: Button = view.findViewById(R.id.button_create_snackbar) - val snackbarLength10: RadioButton = view.findViewById(R.id.radio_button_10_sec) + val snackbarIndefiniteLength: CheckBoxInput = view.findViewById(R.id.infinite_length_checkbox) + val alwaysShowDismiss: CheckBoxInput = view.findViewById(R.id.always_show_dismiss_checkbox) with(dropDownInput.dropDown) { setAdapter( @@ -51,21 +52,34 @@ class SnackBarCatalogFragment : Fragment() { SnackbarBuilder(view, inputText.text.toString()).apply { inputAction.text.toString().let { actionText -> if (actionText.isNotEmpty()) { - withAction(actionText, { }) + withAction(actionText) { } } } - val duration = when { - snackbarLength10.isChecked -> SnackbarLength.LONG - else -> SnackbarLength.SHORT + if (alwaysShowDismiss.isChecked()) { + withDismiss() } + + val withIndefiniteLength = snackbarIndefiniteLength.isChecked() when (SnackBarType.valueOf(dropDownInput.dropDown.text.toString())) { - SnackBarType.INFORMATIVE -> showInformative(duration) - SnackBarType.CRITICAL -> showCritical(duration) + SnackBarType.INFORMATIVE -> show(withIndefiniteLength, SnackbarBuilder::showInformative, SnackbarBuilder::showInformative) + SnackBarType.CRITICAL -> show(withIndefiniteLength, SnackbarBuilder::showCritical, SnackbarBuilder::showCritical) } } } } + private inline fun SnackbarBuilder.show( + withIndefiniteLength: Boolean, + showWithLength: SnackbarBuilder.(SnackbarLength) -> Unit, + showWithoutLength: SnackbarBuilder.() -> Unit, + ) { + if (withIndefiniteLength) { + showWithLength(SnackbarLength.INDEFINITE) + } else { + showWithoutLength() + } + } + private fun View.hideKeyboard() { val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager diff --git a/catalog/src/main/res/layout/screen_snackbar_catalog.xml b/catalog/src/main/res/layout/screen_snackbar_catalog.xml index ece7ced60..8b5cb5db3 100644 --- a/catalog/src/main/res/layout/screen_snackbar_catalog.xml +++ b/catalog/src/main/res/layout/screen_snackbar_catalog.xml @@ -31,41 +31,29 @@ android:inputType="text" app:inputHint="SnackBar Action Text" /> - - - - - + android:layout_gravity="start" + app:inputChecked="false" + app:inputCheckText="Always show dismiss"/> - + android:layout_marginTop="8dp" + android:layout_gravity="start" + app:inputChecked="false" + app:inputCheckText="Infinite length"/> - - - + app:inputHint="SnackBar Type" /> @@ -20,9 +21,25 @@ Builder allows SnackBar customization: * Adds an action with the given string resource and the given click listener * `withCallback(Callback callback)` * Adds a callback for dismiss action. Dismiss action by definition will only work when using a coordinator layout as anchor view for the SnackBar. +* `withDismiss()` + * Adds a dismiss button to the Snackbar layout. -Finally, depending on the type of SnackBar, use one of the following to display it. -These methods allow an argument to choose the duration betweeen SHORT (5 seconds) or LONG (10 seconds). SHORT is the default. +Finally, depending on the type of SnackBar, use one of the following methods to display it: +* `showInformative(snackbarLength: SnackbarLength)` * `showInformative()` -* `showInformative(SnackbarLength.SHORT)` +* `showCritical(snackbarLength: SnackbarLength)` * `showCritical()` + +Where `SnackbarLength` has three different possible values: +* `SHORT`: 5 seconds +* `LONG`: 10 seconds +* `INDEFINITE`: The Snackbar won't dismiss unless it is done manually + +If no `SnackbarLength` is provided, the following logic will be applied: +* If no action is provided: default length will be `SHORT` +* If an action is provided: default length will be `LONG` + +However, if a `SnackbarLength` is provided, the following logic is applied: +- If `LONG` length is provided and there is no action, `SHORT` will be set instead. +- If `SHORT` length is provided and there is an action, `LONG` will be set instead. +- `INFINTE` length is valid with and without an action. diff --git a/library/src/main/java/com/telefonica/mistica/feedback/SnackbarBuilder.kt b/library/src/main/java/com/telefonica/mistica/feedback/SnackbarBuilder.kt index 8b2ac5b27..ce8180dea 100644 --- a/library/src/main/java/com/telefonica/mistica/feedback/SnackbarBuilder.kt +++ b/library/src/main/java/com/telefonica/mistica/feedback/SnackbarBuilder.kt @@ -1,16 +1,19 @@ package com.telefonica.mistica.feedback +import android.annotation.SuppressLint import android.content.res.ColorStateList import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater import android.view.View -import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.StringRes +import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback import com.google.android.material.snackbar.Snackbar import com.telefonica.mistica.R import com.telefonica.mistica.feedback.SnackBarBehaviorConfig.areSticky +import com.telefonica.mistica.feedback.snackbar.CustomSnackbarLayout import com.telefonica.mistica.util.getThemeColor open class SnackbarBuilder(view: View?, text: String) { @@ -20,6 +23,10 @@ open class SnackbarBuilder(view: View?, text: String) { private var actionText: String? = null private var actionListener: View.OnClickListener? = null private var callback: Snackbar.Callback? = null + private var withDismiss = false + + private val hasAction: Boolean + get() = actionText != null constructor(view: View, @StringRes resId: Int) : this(view, view.resources.getString(resId)) @@ -41,6 +48,10 @@ open class SnackbarBuilder(view: View?, text: String) { this.callback = callback } + open fun withDismiss(): SnackbarBuilder = apply{ + this.withDismiss = true + } + @JvmOverloads open fun showInformative(snackbarLength: SnackbarLength = SnackbarLength.SHORT): Snackbar { val spannable = getSpannable(R.attr.colorTextPrimaryInverse) @@ -62,7 +73,7 @@ open class SnackbarBuilder(view: View?, text: String) { } private fun setActionTextColor(snackbar: Snackbar, @AttrRes colorRes: Int) { - snackbar.setActionTextColor(view.context.getThemeColor(colorRes)) + snackbar.getCustomLayout().setActionTextColor(view.context.getThemeColor(colorRes)) } private fun setBackgroundColor(snackbar: Snackbar, @AttrRes colorRes: Int) { @@ -82,47 +93,100 @@ open class SnackbarBuilder(view: View?, text: String) { return spannable } - @Suppress("DEPRECATION") private fun createSnackbar(text: CharSequence, snackbarLength: SnackbarLength): Snackbar { val duration = when { - areSticky() -> Snackbar.LENGTH_INDEFINITE - actionText != null -> SnackbarLength.LONG.duration() - else -> snackbarLength.duration() - } - val snackbar = Snackbar.make(view, text, duration) - setTextStyles(snackbar) - if (actionText != null) { - snackbar.setAction(actionText, actionListener) - } - if (callback != null) { - snackbar.setCallback(callback) - } + areSticky() -> SnackbarLength.INDEFINITE + isInvalidLengthWhenThereIsAction(snackbarLength) -> SnackbarLength.LONG + isInvalidLengthWhenThereIsNoAction(snackbarLength) -> SnackbarLength.SHORT + else -> snackbarLength + }.duration() + + val snackbar = inflateCustomSnackbar(duration) + + snackbar.getCustomLayout().setText(text) + snackbar.setCustomAction() + snackbar.showDismissActionIfNeeded(hasInfiniteDuration = snackbarLength == SnackbarLength.INDEFINITE) + snackbar.addCallbackIfNeeded() + + return snackbar + } + + private fun isInvalidLengthWhenThereIsAction(length: SnackbarLength): Boolean = + hasAction && length == SnackbarLength.SHORT + + private fun isInvalidLengthWhenThereIsNoAction(length: SnackbarLength): Boolean = + !hasAction && length == SnackbarLength.LONG + + // We are inflating a custom layout instead of reusing existing Snackbar layout implementation because + // we need to add a dismiss X button and despite of it being included by Material 3 definition, + // that dismiss button is not supported at the moment in the Android Material library. + // See: https://github.com/material-components/material-components-android/issues/3049 + @SuppressLint("ShowToast") + private fun inflateCustomSnackbar(duration: Int): Snackbar { + // Since we are inflating a custom layout, we pass a dummy text and apply + // the expected one later on to our custom TextView + val snackbar = Snackbar.make(view, "", duration) + val snackbarLayout = snackbar.view as Snackbar.SnackbarLayout + + snackbarLayout.removeAllViews() + val customLayout = LayoutInflater.from(snackbarLayout.context).inflate(R.layout.snackbar_layout, snackbarLayout, false) + snackbarLayout.addView(customLayout) + return snackbar } - @Suppress("DEPRECATION") - private fun setTextStyles(snackbar: Snackbar) { - val text = snackbar.view.findViewById(R.id.snackbar_text) - text.maxLines = MAX_TEXT_LINES - text.setTextAppearance(text.context, R.style.AppTheme_TextAppearance_Preset2) - val action = snackbar.view.findViewById(R.id.snackbar_action) - action.setTextAppearance(action.context, R.style.AppTheme_TextAppearance_PresetLink) - action.isAllCaps = false + private fun Snackbar.setCustomAction() { + actionText?.let { text -> + getCustomLayout().setAction( + actionText = text, + listener = { + actionListener?.onClick(it) + dispatchDismissedByActionEvent() + } + ) + } } - companion object { - private const val MAX_TEXT_LINES = 4 + private fun Snackbar.dispatchDismissedByActionEvent() { + // We are overwriting the Snackbar implementation in order to have support for certain UI elements like the dismiss button. + // Given that, we are losing some built in capabilities such as BaseCallback.DISMISS_EVENT_ACTION event. + // The correct way to dispatch a BaseCallback.DISMISS_EVENT_ACTION event would be to use Snackbar::dispatchDismiss method. + // However that method is protected (we don't have access to it) and invoking Snackbar::dismiss with a registered callback would trigger + // the DISMISS_EVENT_MANUAL event. The workaround is to remove the callback if present, manually invoke the callback method and then invoking + // a dismiss that won't trigger a second event. + removeCallback(callback) + callback?.onDismissed(this, BaseCallback.DISMISS_EVENT_ACTION) + dismiss() + } + + private fun Snackbar.getCustomLayout(): CustomSnackbarLayout = + this.view.findViewById(R.id.custom_layout) + + private fun Snackbar.showDismissActionIfNeeded(hasInfiniteDuration: Boolean) { + val userShouldBeAbleToDismissSnackbar = !hasAction && hasInfiniteDuration + + if (withDismiss || userShouldBeAbleToDismissSnackbar) { + getCustomLayout().setOnDismissClickListener { dismiss() } + } + } + + private fun Snackbar.addCallbackIfNeeded() { + if (callback != null) { + addCallback(callback) + } } } enum class SnackbarLength { SHORT, - LONG; + LONG, + INDEFINITE; fun duration(): Int = when (this) { SHORT -> DURATION_WITHOUT_ACTION LONG -> DURATION_WITH_ACTION + INDEFINITE -> Snackbar.LENGTH_INDEFINITE } companion object { diff --git a/library/src/main/java/com/telefonica/mistica/feedback/snackbar/CustomSnackbarLayout.kt b/library/src/main/java/com/telefonica/mistica/feedback/snackbar/CustomSnackbarLayout.kt new file mode 100644 index 000000000..09a6a8f95 --- /dev/null +++ b/library/src/main/java/com/telefonica/mistica/feedback/snackbar/CustomSnackbarLayout.kt @@ -0,0 +1,95 @@ +package com.telefonica.mistica.feedback.snackbar + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import com.telefonica.mistica.R + +internal class CustomSnackbarLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +): ConstraintLayout(context, attrs, defStyleAttr) { + + private val maxActionLengthPx: Int + + init { + LayoutInflater.from(context).inflate(R.layout.snackbar_custom_layout_merge, this, true) + + maxActionLengthPx = context.resources.getDimensionPixelSize(R.dimen.mistica_snackbar_maxActionInlineWidth) + } + + fun setText(text: CharSequence) { + getText().text = text + } + + fun setAction(actionText: CharSequence, listener: OnClickListener) { + getAction().run { + visibility = View.VISIBLE + text = actionText + setOnClickListener(listener) + } + } + + fun setActionTextColor(@ColorInt color: Int) { + getAction().setTextColor(color) + } + + fun setOnDismissClickListener(onDismissed: () -> Unit) { + getDismissButton().visibility = View.VISIBLE + getDismissButton().setOnClickListener { onDismissed() } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + if (actionIsTooLong()) { + rearrangeLayout() + } + } + + private fun actionIsTooLong(): Boolean = + getAction().measuredWidth > maxActionLengthPx + + private fun rearrangeLayout() { + val text = getText() + val action = getAction() + val dismissButton = getDismissButton() + val parent = this + + val constraintSet = ConstraintSet() + constraintSet.clone(parent) + + constraintSet.connect(text.id, ConstraintSet.END, dismissButton.id, ConstraintSet.START) + constraintSet.connect(text.id, ConstraintSet.BOTTOM, action.id, ConstraintSet.TOP) + + constraintSet.connect(dismissButton.id, ConstraintSet.START, text.id, ConstraintSet.END) + constraintSet.connect(dismissButton.id, ConstraintSet.END, parent.id, ConstraintSet.END) + constraintSet.setHorizontalBias(dismissButton.id, 1.0F) + constraintSet.setVerticalBias(dismissButton.id, 0.0F) + constraintSet.setMargin(dismissButton.id, ConstraintSet.TOP, context.resources.getDimensionPixelSize(R.dimen.mistica_snackbar_padding_vertical)) + + constraintSet.connect(action.id, ConstraintSet.START, parent.id, ConstraintSet.START) + constraintSet.connect(action.id, ConstraintSet.BOTTOM, parent.id, ConstraintSet.BOTTOM) + constraintSet.connect(action.id, ConstraintSet.TOP, text.id, ConstraintSet.BOTTOM) + constraintSet.connect(action.id, ConstraintSet.END, parent.id, ConstraintSet.END) + constraintSet.setHorizontalBias(action.id, 1.0F) + constraintSet.setMargin(action.id, ConstraintSet.END, context.resources.getDimensionPixelSize(R.dimen.mistica_snackbar_padding_horizontal)) + + constraintSet.applyTo(this) + } + + private fun getText(): TextView = + findViewById(R.id.custom_snackbar_text) + + private fun getAction(): TextView = + findViewById(R.id.custom_snackbar_action) + + private fun getDismissButton(): View = + findViewById(R.id.custom_snackbar_dismiss) +} diff --git a/library/src/main/res/layout/snackbar_custom_layout_merge.xml b/library/src/main/res/layout/snackbar_custom_layout_merge.xml new file mode 100644 index 000000000..a4e8ab913 --- /dev/null +++ b/library/src/main/res/layout/snackbar_custom_layout_merge.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/library/src/main/res/layout/snackbar_layout.xml b/library/src/main/res/layout/snackbar_layout.xml new file mode 100644 index 000000000..b9f23f903 --- /dev/null +++ b/library/src/main/res/layout/snackbar_layout.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/library/src/main/res/values/dimens_snackbar.xml b/library/src/main/res/values/dimens_snackbar.xml new file mode 100644 index 000000000..5a72531fe --- /dev/null +++ b/library/src/main/res/values/dimens_snackbar.xml @@ -0,0 +1,8 @@ + + + 14dp + 12dp + 128dp + 128dp + 4 + \ No newline at end of file