Skip to content

Commit

Permalink
For mozilla-mobile#7833: Introduce async drawable menu item
Browse files Browse the repository at this point in the history
  • Loading branch information
NotWoods committed Sep 26, 2020
1 parent 85a6d33 commit 101eb82
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions components/browser/menu2/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation Dependencies.androidx_constraintlayout

implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines

testImplementation project(':support-test')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T : MenuIcon>(
parent: ConstraintLayout,
Expand Down Expand Up @@ -137,3 +145,80 @@ 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<AsyncDrawableMenuIcon>(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
}
}

@Suppress("TooGenericExceptionCaught")
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<TextView>(R.id.icon)
doReturn(imageButton).`when`(imageButton).findViewById<TextView>(R.id.icon)
Expand All @@ -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<Drawable>()
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<Logger>()
val holder = AsyncDrawableMenuIconViewHolder(parent, layoutInflater, Side.END, logger)

val loading = mock<Drawable>()
val fallback = mock<Drawable>()
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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
8 changes: 5 additions & 3 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ permalink: /changelog/
* **feature-tabs**
* Added `TabsUseCases.UndoTabRemovalUseCase` for undoing the removal of tabs.
* **feature-webcompat-reporter**
* Added the ability to automatically add a screenshot as well as more technical details when submitting a WebCompat report.
* **feature-addons**
* ⚠️ This is a breaking change for call sites that don't rely on named arguments: `AddonCollectionProvider` now supports configuring a custom collection owner (via AMO user ID or name).
* Added the ability to automatically add a screenshot as well as more technical details when submitting a WebCompat report.
* **feature-addons**
* ⚠️ This is a breaking change for call sites that don't rely on named arguments: `AddonCollectionProvider` now supports configuring a custom collection owner (via AMO user ID or name).
```kotlin
val addonCollectionProvider by lazy {
AddonCollectionProvider(
Expand All @@ -32,6 +32,8 @@ permalink: /changelog/
}
* 🚒 Bug fixed [issue #8267](https://github.com/mozilla-mobile/android-components/issues/8267) Devtools permission had wrong translation.
```
* **concept-menu**
* 🌟 Added `AsyncDrawableMenuIcon` class to use icons in a menu that will be loaded later.

# 60.0.0

Expand Down

0 comments on commit 101eb82

Please sign in to comment.