Skip to content

Commit

Permalink
New Snackbar spec (#306)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
yamal-alm authored Oct 27, 2023
1 parent 01ec87b commit 32738df
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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
Expand Down
44 changes: 16 additions & 28 deletions catalog/src/main/res/layout/screen_snackbar_catalog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,41 +31,29 @@
android:inputType="text"
app:inputHint="SnackBar Action Text" />

<com.telefonica.mistica.input.DropDownInput
android:id="@+id/dropdown_snackbar_type"
android:layout_width="match_parent"
<com.telefonica.mistica.input.CheckBoxInput
android:id="@+id/always_show_dismiss_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:inputHint="SnackBar Type" />

<RadioGroup
android:id="@+id/radio_group_snackbar_length"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:orientation="horizontal">

<RadioButton
android:id="@+id/radio_button_5_sec"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="5 seconds" />
android:layout_gravity="start"
app:inputChecked="false"
app:inputCheckText="Always show dismiss"/>

<RadioButton
android:id="@+id/radio_button_10_sec"
<com.telefonica.mistica.input.CheckBoxInput
android:id="@+id/infinite_length_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="10 seconds" />
android:layout_marginTop="8dp"
android:layout_gravity="start"
app:inputChecked="false"
app:inputCheckText="Infinite length"/>

</RadioGroup>

<TextView
android:layout_width="wrap_content"
<com.telefonica.mistica.input.DropDownInput
android:id="@+id/dropdown_snackbar_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="Time only applies if no action set" />
app:inputHint="SnackBar Type" />

<com.telefonica.mistica.button.Button
android:id="@+id/button_create_snackbar"
Expand Down
25 changes: 21 additions & 4 deletions library/src/main/java/com/telefonica/mistica/feedback/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Snackbars

Snackbars allow to show contextual information usually after the user has done any action. Snackbars are displayed during 5 seconds on the screen if there isn't an action associated, and 10 when is it. There are also two types, informative and critical:
Snackbars allow to show contextual information usually after the user has done any action. There are two types, informative and critical and they are
displayed during 5 seconds, 10 seconds or during an indefinite amount of time depending on the duration specified.

<p align="center">
<img src="../../../../../../../../doc/images/snackbars/snackbars_informative.gif">
Expand All @@ -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.
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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))

Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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<TextView>(R.id.snackbar_text)
text.maxLines = MAX_TEXT_LINES
text.setTextAppearance(text.context, R.style.AppTheme_TextAppearance_Preset2)
val action = snackbar.view.findViewById<TextView>(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 {
Expand Down
Loading

0 comments on commit 32738df

Please sign in to comment.