diff --git a/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt b/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt index d7ffaa1a0cc..d9b845caf4f 100644 --- a/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt +++ b/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt @@ -13,16 +13,15 @@ import androidx.appcompat.content.res.AppCompatResources.getDrawable import androidx.core.graphics.drawable.toDrawable import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import mozilla.components.browser.menu.BrowserMenu import mozilla.components.browser.menu.BrowserMenuItem import mozilla.components.browser.menu.R +import mozilla.components.concept.engine.webextension.Action +import mozilla.components.concept.menu.candidate.AsyncDrawableMenuIcon import mozilla.components.concept.menu.candidate.ContainerStyle -import mozilla.components.concept.menu.candidate.DrawableMenuIcon import mozilla.components.concept.menu.candidate.TextMenuCandidate import mozilla.components.concept.menu.candidate.TextMenuIcon import mozilla.components.concept.menu.candidate.TextStyle -import mozilla.components.concept.engine.webextension.Action import mozilla.components.support.base.log.Log /** @@ -83,17 +82,18 @@ class WebExtensionBrowserMenuItem( override fun asCandidate(context: Context) = TextMenuCandidate( action.title.orEmpty(), - start = runBlocking { - val height = context.resources - .getDimensionPixelSize(R.dimen.mozac_browser_menu_item_web_extension_icon_height) - loadIcon(context, height)?.let { DrawableMenuIcon(it) } - }, - end = TextMenuIcon( - action.badgeText.orEmpty(), - textStyle = TextStyle( - color = action.badgeTextColor - ) + start = AsyncDrawableMenuIcon( + loadDrawable = { _, height -> loadIcon(context, height) } ), + end = action.badgeText?.let { badgeText -> + TextMenuIcon( + badgeText, + backgroundTint = action.badgeBackgroundColor, + textStyle = TextStyle( + color = action.badgeTextColor + ) + ) + }, containerStyle = ContainerStyle( isVisible = visible(), isEnabled = action.enabled ?: false diff --git a/components/browser/menu2/build.gradle b/components/browser/menu2/build.gradle index 4b908312e69..63f688f22fb 100644 --- a/components/browser/menu2/build.gradle +++ b/components/browser/menu2/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation Dependencies.androidx_constraintlayout implementation Dependencies.kotlin_stdlib + implementation Dependencies.kotlin_coroutines testImplementation project(':support-test') diff --git a/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHolders.kt b/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHolders.kt index 72586a346e2..e18702a1764 100644 --- a/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHolders.kt +++ b/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHolders.kt @@ -4,6 +4,8 @@ package mozilla.components.browser.menu2.adapter.icons +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View import android.widget.ImageButton @@ -16,14 +18,20 @@ import androidx.constraintlayout.widget.ConstraintSet.PARENT_ID import androidx.constraintlayout.widget.ConstraintSet.START import androidx.constraintlayout.widget.ConstraintSet.TOP import androidx.core.view.isVisible +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import mozilla.components.browser.menu2.R +import mozilla.components.browser.menu2.ext.applyIcon +import mozilla.components.browser.menu2.ext.applyNotificationEffect import mozilla.components.concept.menu.Side +import mozilla.components.concept.menu.candidate.AsyncDrawableMenuIcon import mozilla.components.concept.menu.candidate.DrawableButtonMenuIcon import mozilla.components.concept.menu.candidate.DrawableMenuIcon import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect import mozilla.components.concept.menu.candidate.MenuIcon -import mozilla.components.browser.menu2.ext.applyIcon -import mozilla.components.browser.menu2.ext.applyNotificationEffect +import mozilla.components.support.base.log.logger.Logger internal abstract class MenuIconWithDrawableViewHolder( parent: ConstraintLayout, @@ -137,3 +145,79 @@ internal class DrawableButtonMenuIconViewHolder( val layoutResource = R.layout.mozac_browser_menu2_icon_button } } + +internal class AsyncDrawableMenuIconViewHolder( + parent: ConstraintLayout, + inflater: LayoutInflater, + side: Side, + private val logger: Logger = Logger("mozac-menu2") +) : MenuIconWithDrawableViewHolder(parent, inflater) { + + private val scope = MainScope() + override val imageView: ImageView = inflate(layoutResource).findViewById(R.id.icon) + private var effectView: ImageView? = null + private var iconJob: Job? = null + + init { + setup(imageView, side) + } + + override fun bind(newIcon: AsyncDrawableMenuIcon, oldIcon: AsyncDrawableMenuIcon?) { + if (newIcon.loadDrawable != oldIcon?.loadDrawable) { + imageView.setImageDrawable(newIcon.loadingDrawable) + iconJob?.cancel() + iconJob = scope.launch { loadIcon(newIcon.loadDrawable, newIcon.fallbackDrawable) } + } + + if (newIcon.tint != oldIcon?.tint) { + imageView.imageTintList = newIcon.tint?.let { ColorStateList.valueOf(it) } + } + + // Only inflate the effect container if needed + if (newIcon.effect != null) { + createEffectView().applyNotificationEffect(newIcon.effect as LowPriorityHighlightEffect, oldIcon?.effect) + } else { + effectView?.isVisible = false + } + } + + private suspend fun loadIcon( + loadDrawable: suspend (width: Int, height: Int) -> Drawable?, + fallback: Drawable? + ) { + val drawable = try { + loadDrawable(imageView.measuredWidth, imageView.measuredHeight) + } catch (throwable: Throwable) { + logger.error( + message = "Failed to load browser action icon, falling back to default.", + throwable = throwable + ) + fallback + } + imageView.setImageDrawable(drawable) + } + + private fun createEffectView(): ImageView { + if (effectView == null) { + val effect: ImageView = inflate(notificationDotLayoutResource).findViewById(R.id.notification_dot) + updateConstraints { + connect(effect.id, TOP, imageView.id, TOP) + connect(effect.id, END, imageView.id, END) + } + effectView = effect + } + return effectView!! + } + + override fun disconnect() { + effectView?.let { parent.removeView(it) } + scope.cancel() + super.disconnect() + } + + companion object { + @LayoutRes + val layoutResource = R.layout.mozac_browser_menu2_icon_drawable + val notificationDotLayoutResource = R.layout.mozac_browser_menu2_icon_notification_dot + } +} diff --git a/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/MenuIconAdapter.kt b/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/MenuIconAdapter.kt index 29599b74d18..9a564d648c8 100644 --- a/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/MenuIconAdapter.kt +++ b/components/browser/menu2/src/main/java/mozilla/components/browser/menu2/adapter/icons/MenuIconAdapter.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.ConstraintLayout import mozilla.components.concept.menu.Side +import mozilla.components.concept.menu.candidate.AsyncDrawableMenuIcon import mozilla.components.concept.menu.candidate.DrawableButtonMenuIcon import mozilla.components.concept.menu.candidate.DrawableMenuIcon import mozilla.components.concept.menu.candidate.MenuIcon @@ -44,6 +45,7 @@ internal class MenuIconAdapter( internal fun createViewHolder(item: MenuIcon): MenuIconViewHolder<*> = when (item) { is DrawableMenuIcon -> DrawableMenuIconViewHolder(parent, inflater, side) is DrawableButtonMenuIcon -> DrawableButtonMenuIconViewHolder(parent, inflater, side, dismiss) + is AsyncDrawableMenuIcon -> AsyncDrawableMenuIconViewHolder(parent, inflater, side) is TextMenuIcon -> TextMenuIconViewHolder(parent, inflater, side) } } diff --git a/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHoldersTest.kt b/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHoldersTest.kt index 6a1bc73f94b..0890f61186c 100644 --- a/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHoldersTest.kt +++ b/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/adapter/icons/DrawableMenuIconViewHoldersTest.kt @@ -11,25 +11,39 @@ import android.widget.ImageView import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest import mozilla.components.browser.menu2.R import mozilla.components.concept.menu.Side +import mozilla.components.concept.menu.candidate.AsyncDrawableMenuIcon import mozilla.components.concept.menu.candidate.DrawableButtonMenuIcon import mozilla.components.concept.menu.candidate.DrawableMenuIcon +import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.test.any import mozilla.components.support.test.mock import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.clearInvocations import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never import org.mockito.Mockito.verify +@ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) class DrawableMenuIconViewHoldersTest { + private val testDispatcher = TestCoroutineDispatcher() + + @get:Rule + val coroutinesTestRule = MainCoroutineRule(testDispatcher) + private lateinit var parent: ConstraintLayout private lateinit var layoutInflater: LayoutInflater private lateinit var imageView: ImageView @@ -45,6 +59,7 @@ class DrawableMenuIconViewHoldersTest { doReturn(testContext).`when`(parent).context doReturn(testContext.resources).`when`(parent).resources doReturn(imageView).`when`(layoutInflater).inflate(DrawableMenuIconViewHolder.layoutResource, parent, false) + doReturn(imageView).`when`(layoutInflater).inflate(AsyncDrawableMenuIconViewHolder.layoutResource, parent, false) doReturn(imageButton).`when`(layoutInflater).inflate(DrawableButtonMenuIconViewHolder.layoutResource, parent, false) doReturn(imageView).`when`(imageView).findViewById(R.id.icon) doReturn(imageButton).`when`(imageButton).findViewById(R.id.icon) @@ -71,6 +86,36 @@ class DrawableMenuIconViewHoldersTest { verify(imageButton).imageTintList = null } + @Test + fun `async view holder sets icon on view`() = testDispatcher.runBlockingTest { + val holder = AsyncDrawableMenuIconViewHolder(parent, layoutInflater, Side.END) + + val drawable = mock() + holder.bindAndCast(AsyncDrawableMenuIcon(loadDrawable = { _, _ -> drawable }), null) + verify(imageView).setImageDrawable(null) + verify(imageView).setImageDrawable(drawable) + } + + @Test + fun `async view holder uses loading icon and fallback icon`() = testDispatcher.runBlockingTest { + val logger = mock() + val holder = AsyncDrawableMenuIconViewHolder(parent, layoutInflater, Side.END, logger) + + val loading = mock() + val fallback = mock() + holder.bindAndCast( + AsyncDrawableMenuIcon( + loadDrawable = { _, _ -> throw Exception() }, + loadingDrawable = loading, + fallbackDrawable = fallback + ), + null + ) + verify(imageView, never()).setImageDrawable(null) + verify(imageView).setImageDrawable(loading) + verify(imageView).setImageDrawable(fallback) + } + @Test fun `icon holder removes image view on disconnect`() { val holder = DrawableMenuIconViewHolder(parent, layoutInflater, Side.START) @@ -99,6 +144,20 @@ class DrawableMenuIconViewHoldersTest { verify(parent).removeView(imageButton) } + @Test + fun `async holder removes image view on disconnect`() { + val holder = AsyncDrawableMenuIconViewHolder(parent, layoutInflater, Side.START) + + verify(parent).setConstraintSet(any()) + verify(parent).addView(imageView) + clearInvocations(parent) + + holder.disconnect() + + verify(parent).setConstraintSet(any()) + verify(parent).removeView(imageView) + } + @Test fun `button view holder calls dismiss when clicked`() { var dismissed = false diff --git a/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt b/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt index 2a4efbeeae7..ca9a90bc942 100644 --- a/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt +++ b/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt @@ -57,6 +57,23 @@ data class DrawableButtonMenuIcon( ) : this(AppCompatResources.getDrawable(context, resource), tint, onClick) } +/** + * Menu icon that displays a drawable. + * + * @property loadDrawable Function that creates drawable icon to display. + * @property loadingDrawable Drawable that is displayed while loadDrawable is running. + * @property fallbackDrawable Drawable that is displayed if loadDrawable fails. + * @property tint Tint to apply to the drawable. + * @property effect Effects to apply to the icon. + */ +data class AsyncDrawableMenuIcon( + val loadDrawable: suspend (width: Int, height: Int) -> Drawable?, + val loadingDrawable: Drawable? = null, + val fallbackDrawable: Drawable? = null, + @ColorInt val tint: Int? = null, + val effect: MenuIconEffect? = null +) : MenuIcon() + /** * Menu icon to display additional text at the end of a menu option. * diff --git a/docs/changelog.md b/docs/changelog.md index 9800e65ef03..4e02345880a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -24,6 +24,9 @@ permalink: /changelog/ * Implements TopSitesFeature based on the RFC [0006-top-sites-feature.md](https://github.com/mozilla-mobile/android-components/blob/master/docs/rfcs/0006-top-sites-feature.md). * Downloads, redirect targets, reloads, embedded resources, and frames are no longer considered for inclusion in top sites. Please see [this Application Services PR](https://github.com/mozilla/application-services/pull/3505) for more details. +* **concept-menu** + * 🌟 Added `AsyncDrawableMenuIcon` class to use icons in a menu that will be loaded later. + # 56.0.0 * [Commits](https://github.com/mozilla-mobile/android-components/compare/v55.0.0...v56.0.0)