Revision control
Copy as Markdown
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
package org.mozilla.focus.browser
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.graphics.Color
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import kotlinx.android.synthetic.main.home_tile.view.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
import org.mozilla.focus.R
import org.mozilla.focus.ext.forceExhaustive
import org.mozilla.focus.ext.serviceLocator
import org.mozilla.focus.ext.toJavaURI
import org.mozilla.focus.ext.withRoundedCorners
import org.mozilla.focus.home.BundledHomeTile
import org.mozilla.focus.home.BundledTilesManager
import org.mozilla.focus.home.CustomHomeTile
import org.mozilla.focus.home.HomeTile
import org.mozilla.focus.home.HomeTilePlaceholderGenerator
import org.mozilla.focus.home.HomeTileScreenshotStore
import org.mozilla.focus.home.HomeTilesManager
import org.mozilla.focus.home.TileAction
import org.mozilla.focus.telemetry.TelemetryWrapper
import org.mozilla.focus.utils.FormattedDomain
/**
* Duration of animation to show custom tile. If the duration is too short, the tile will just
* pop-in. I speculate this happens because the amount of time it takes to downsample the bitmap
* is longer than the animation duration.
*/
private const val CUSTOM_TILE_TO_SHOW_MILLIS = 200L
private val CUSTOM_TILE_ICON_INTERPOLATOR = DecelerateInterpolator()
typealias ExecuteSearch = (String, InlineAutocompleteEditText.AutocompleteResult) -> Unit
class HomeTileAdapter(
private val uiScope: CoroutineScope,
private var tiles: MutableList<HomeTile>,
private val loadUrl: (String) -> Unit,
private val homeTileLongClickListenerProvider: () -> HomeTileLongClickListener?,
var onTileFocused: (() -> Unit)?
) : RecyclerView.Adapter<TileViewHolder>() {
override fun onBindViewHolder(holder: TileViewHolder, position: Int) = with(holder) {
val item = tiles[position]
when (item) {
is BundledHomeTile -> {
onBindBundledHomeTile(holder, item)
setIconLayoutMarginParams(iconView, R.dimen.home_tile_margin_value)
}
is CustomHomeTile -> {
onBindCustomHomeTile(uiScope, holder, item)
setIconLayoutMarginParams(iconView, R.dimen.home_tile_margin_value)
}
}.forceExhaustive
if (item is BundledHomeTile && item.action == TileAction.SEARCH && itemView is ViewGroup) {
itemView.setSearchClickListeners(item)
// This removes any existing listener. setOnLongClickListener(null)
// didn't clear the listener for an unknown reason
itemView.setOnLongClickListener { true }
} else {
itemView.setNavigateClickListener(item)
itemView.setOnLongClickListener(getDefaultLongClickListener(item))
}
val tvWhiteColor = ContextCompat.getColor(holder.itemView.context, R.color.tv_white)
itemView.setOnFocusChangeListener { _, hasFocus ->
val backgroundResource: Int
val textColor: Int
if (hasFocus) {
backgroundResource = R.drawable.home_tile_title_focused_background
textColor = tvWhiteColor
onTileFocused?.invoke()
} else {
backgroundResource = 0
textColor = Color.BLACK
}
titleView.setBackgroundResource(backgroundResource)
titleView.setTextColor(textColor)
}
}
private fun ViewGroup.setSearchClickListeners(item: HomeTile) {
this.setOnClickListener {
context.serviceLocator.pinnedTileRepo.googleSearchFocusRequest()
TelemetryWrapper.homeTileClickEvent(item)
}
}
private fun View.setNavigateClickListener(item: HomeTile) {
this.setOnClickListener {
loadUrl(item.url)
TelemetryWrapper.homeTileClickEvent(item)
}
}
private fun getDefaultLongClickListener(item: HomeTile) = View.OnLongClickListener {
homeTileLongClickListenerProvider()?.onHomeTileLongClick(unpinTile = {
HomeTilesManager.removeHomeTile(item, it.context)
removeTile(item.idToString())
TelemetryWrapper.homeTileRemovedEvent(item)
})
true
}
private fun setIconLayoutMarginParams(iconView: View, tileMarginValue: Int) {
val layoutMarginParams = iconView.layoutParams as ViewGroup.MarginLayoutParams
val marginValue = iconView.resources.getDimensionPixelSize(tileMarginValue)
layoutMarginParams.setMargins(marginValue, marginValue, marginValue, marginValue)
iconView.layoutParams = layoutMarginParams
}
/**
* takes in the home tiles cache and updates the adapter's data source
* and UI accordingly, assuming only one new tile is added
*/
fun updateAdapterSingleInsertion(homeTiles: MutableList<HomeTile>) {
if (homeTiles.size == tiles.size) {
// The lists must not be the same size in order
// for an insertion to be valid
return
}
for ((index, tile) in tiles.withIndex()) {
// Due to insertion, the inserted tile will be
// the first tile that will not match the
// previous list of tiles
if (tile != homeTiles[index]) {
tiles = homeTiles
notifyItemInserted(index)
return
}
}
tiles = homeTiles
notifyItemInserted(homeTiles.lastIndex)
}
fun removeTile(tileId: String) {
for ((index, tile) in tiles.withIndex()) {
if (tile is CustomHomeTile && tile.id.toString() == tileId || tile is BundledHomeTile && tile.id == tileId) {
removeTile(index)
break
}
}
}
fun removeTile(position: Int) {
if (position > -1 && position < itemCount) {
tiles.removeAt(position)
notifyItemRemoved(position)
}
}
override fun getItemCount() = tiles.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TileViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.home_tile, parent, false)
)
}
private fun onBindBundledHomeTile(holder: TileViewHolder, tile: BundledHomeTile) = with(holder) {
val bitmap = BundledTilesManager.getInstance(itemView.context).loadImageFromPath(itemView.context, tile.imagePath)
iconView.setImageBitmap(bitmap)
// TODO remove hardcoded search tile title
// This is necessary while bundled tiles are loaded from JSON (which cannot
// reference string resources)
if (tile.action == TileAction.SEARCH) {
titleView.text = iconView.resources.getString(R.string.google_search_tile_title)
} else {
titleView.text = tile.title
}
}
private fun onBindCustomHomeTile(uiScope: CoroutineScope, holder: TileViewHolder, item: CustomHomeTile) = with(holder) {
uiScope.launch {
val validUri = item.url.toJavaURI()
val screenshotDeferred = async {
val homeTileCornerRadius = itemView.resources.getDimension(R.dimen.home_tile_corner_radius)
val homeTilePlaceholderCornerRadius = itemView.resources.getDimension(R.dimen.home_tile_placeholder_corner_radius)
val screenshot = HomeTileScreenshotStore.read(itemView.context, item.id)?.withRoundedCorners(homeTileCornerRadius)
screenshot ?: HomeTilePlaceholderGenerator.generate(itemView.context, item.url)
.withRoundedCorners(homeTilePlaceholderCornerRadius)
}
val titleDeferred = if (validUri == null) {
CompletableDeferred(item.url)
} else {
async {
val subdomainDotDomain = FormattedDomain.format(itemView.context, validUri, false, 1)
FormattedDomain.stripCommonPrefixes(subdomainDotDomain)
}
}
// We wait for both to complete so we can animate them together.
val screenshot = screenshotDeferred.await()
val title = titleDeferred.await()
// NB: Don't suspend after this point (i.e. between view updates like setImage)
// so we don't see intermediate view states.
// TODO: It'd be less error-prone to launch { /* bg work */ launch(UI) { /* UI work */ } }
iconView.setImageBitmap(screenshot)
titleView.text = title
// Animate to avoid pop-in due to thread hand-offs. TODO: animation is janky.
AnimatorSet().apply {
interpolator = CUSTOM_TILE_ICON_INTERPOLATOR
duration = CUSTOM_TILE_TO_SHOW_MILLIS
val iconAnim = ObjectAnimator.ofInt(iconView, "imageAlpha", 0, 255)
val titleAnim = ObjectAnimator.ofFloat(titleView, "alpha", 0f, 1f)
playTogether(iconAnim, titleAnim)
}.start()
}
}
class TileViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
val iconView = itemView.tile_icon
val titleView = itemView.tile_title
}