[fenix] For https://github.com/mozilla-mobile/fenix/issues/904 -Add tab counter to tab icon
parent
9404ffba55
commit
332e7d58f3
@ -0,0 +1,309 @@
|
||||
/* 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
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.components.toolbar
|
||||
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.ImageView
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import mozilla.components.support.utils.DrawableUtils
|
||||
import mozilla.components.ui.tabcounter.R
|
||||
import java.text.NumberFormat
|
||||
|
||||
open class TabCounter @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0
|
||||
) : RelativeLayout(context, attrs, defStyle) {
|
||||
|
||||
private val box: ImageView
|
||||
private val text: TextView
|
||||
|
||||
private val animationSet: AnimatorSet
|
||||
private var count: Int = 0
|
||||
private var currentTextRatio: Float = 0.toFloat()
|
||||
|
||||
init {
|
||||
val inflater = LayoutInflater.from(context)
|
||||
inflater.inflate(R.layout.mozac_ui_tabcounter_layout, this)
|
||||
|
||||
box = findViewById(R.id.counter_box)
|
||||
text = findViewById(R.id.counter_text)
|
||||
text.text = DEFAULT_TABS_COUNTER_TEXT
|
||||
val shiftOneDpForDefaultText = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 1f, context.resources.displayMetrics
|
||||
).toInt()
|
||||
text.setPadding(0, 0, 0, shiftOneDpForDefaultText)
|
||||
|
||||
animationSet = createAnimatorSet()
|
||||
}
|
||||
|
||||
fun getText(): CharSequence {
|
||||
return text.text
|
||||
}
|
||||
|
||||
fun setCountWithAnimation(count: Int) {
|
||||
// Don't animate from initial state.
|
||||
if (this.count == 0) {
|
||||
setCount(count)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.count == count) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't animate if there are still over MAX_VISIBLE_TABS tabs open.
|
||||
if (this.count > MAX_VISIBLE_TABS && count > MAX_VISIBLE_TABS) {
|
||||
this.count = count
|
||||
return
|
||||
}
|
||||
|
||||
adjustTextSize(count)
|
||||
|
||||
text.setPadding(0, 0, 0, 0)
|
||||
text.text = formatForDisplay(count)
|
||||
this.count = count
|
||||
|
||||
// Cancel previous animations if necessary.
|
||||
if (animationSet.isRunning) {
|
||||
animationSet.cancel()
|
||||
}
|
||||
// Trigger animations.
|
||||
animationSet.start()
|
||||
}
|
||||
|
||||
fun setCount(count: Int) {
|
||||
adjustTextSize(count)
|
||||
|
||||
text.setPadding(0, 0, 0, 0)
|
||||
text.text = formatForDisplay(count)
|
||||
this.count = count
|
||||
}
|
||||
|
||||
private fun tintDrawables(tabCounterTint: Int) {
|
||||
val tabCounterBox = DrawableUtils.loadAndTintDrawable(
|
||||
context,
|
||||
R.drawable.mozac_ui_tabcounter_box, tabCounterTint
|
||||
)
|
||||
box.setImageDrawable(tabCounterBox)
|
||||
|
||||
text.setTextColor(tabCounterTint)
|
||||
}
|
||||
|
||||
private fun createAnimatorSet(): AnimatorSet {
|
||||
val animatorSet = AnimatorSet()
|
||||
createBoxAnimatorSet(animatorSet)
|
||||
createTextAnimatorSet(animatorSet)
|
||||
return animatorSet
|
||||
}
|
||||
|
||||
private fun createBoxAnimatorSet(animatorSet: AnimatorSet) {
|
||||
// The first animator, fadeout in 33 ms (49~51, 2 frames).
|
||||
val fadeOut = ObjectAnimator.ofFloat(
|
||||
box, "alpha",
|
||||
ANIM_BOX_FADEOUT_FROM, ANIM_BOX_FADEOUT_TO
|
||||
).setDuration(ANIM_BOX_FADEOUT_DURATION)
|
||||
|
||||
// Move up on y-axis, from 0.0 to -5.3 in 50ms, with fadeOut (49~52, 3 frames).
|
||||
val moveUp1 = ObjectAnimator.ofFloat(
|
||||
box, "translationY",
|
||||
ANIM_BOX_MOVEUP1_TO, ANIM_BOX_MOVEUP1_FROM
|
||||
).setDuration(ANIM_BOX_MOVEUP1_DURATION)
|
||||
|
||||
// Move down on y-axis, from -5.3 to -1.0 in 116ms, after moveUp1 (52~59, 7 frames).
|
||||
val moveDown2 = ObjectAnimator.ofFloat(
|
||||
box, "translationY",
|
||||
ANIM_BOX_MOVEDOWN2_FROM, ANIM_BOX_MOVEDOWN2_TO
|
||||
).setDuration(ANIM_BOX_MOVEDOWN2_DURATION)
|
||||
|
||||
// FadeIn in 66ms, with moveDown2 (52~56, 4 frames).
|
||||
val fadeIn = ObjectAnimator.ofFloat(
|
||||
box, "alpha",
|
||||
ANIM_BOX_FADEIN_FROM, ANIM_BOX_FADEIN_TO
|
||||
).setDuration(ANIM_BOX_FADEIN_DURATION)
|
||||
|
||||
// Move down on y-axis, from -1.0 to 2.7 in 116ms, after moveDown2 (59~66, 7 frames).
|
||||
val moveDown3 = ObjectAnimator.ofFloat(
|
||||
box, "translationY",
|
||||
ANIM_BOX_MOVEDOWN3_FROM, ANIM_BOX_MOVEDOWN3_TO
|
||||
).setDuration(ANIM_BOX_MOVEDOWN3_DURATION)
|
||||
|
||||
// Move up on y-axis, from 2.7 to 0 in 133ms, after moveDown3 (66~74, 8 frames).
|
||||
val moveUp4 = ObjectAnimator.ofFloat(
|
||||
box, "translationY",
|
||||
ANIM_BOX_MOVEDOWN4_FROM, ANIM_BOX_MOVEDOWN4_TO
|
||||
).setDuration(ANIM_BOX_MOVEDOWN4_DURATION)
|
||||
|
||||
// Scale up height from 2% to 105% in 100ms, after moveUp1 and delay 16ms (53~59, 6 frames).
|
||||
val scaleUp1 = ObjectAnimator.ofFloat(
|
||||
box, "scaleY",
|
||||
ANIM_BOX_SCALEUP1_FROM, ANIM_BOX_SCALEUP1_TO
|
||||
).setDuration(ANIM_BOX_SCALEUP1_DURATION)
|
||||
scaleUp1.startDelay = ANIM_BOX_SCALEUP1_DELAY // delay 1 frame after moveUp1
|
||||
|
||||
// Scale down height from 105% to 99% in 116ms, after scaleUp1 (59~66, 7 frames).
|
||||
val scaleDown2 = ObjectAnimator.ofFloat(
|
||||
box, "scaleY",
|
||||
ANIM_BOX_SCALEDOWN2_FROM, ANIM_BOX_SCALEDOWN2_TO
|
||||
).setDuration(ANIM_BOX_SCALEDOWN2_DURATION)
|
||||
|
||||
// Scale up height from 99% to 100% in 133ms, after scaleDown2 (66~74, 8 frames).
|
||||
val scaleUp3 = ObjectAnimator.ofFloat(
|
||||
box, "scaleY",
|
||||
ANIM_BOX_SCALEUP3_FROM, ANIM_BOX_SCALEUP3_TO
|
||||
).setDuration(ANIM_BOX_SCALEUP3_DURATION)
|
||||
|
||||
animatorSet.play(fadeOut).with(moveUp1)
|
||||
animatorSet.play(moveUp1).before(moveDown2)
|
||||
animatorSet.play(moveDown2).with(fadeIn)
|
||||
animatorSet.play(moveDown2).before(moveDown3)
|
||||
animatorSet.play(moveDown3).before(moveUp4)
|
||||
|
||||
animatorSet.play(moveUp1).before(scaleUp1)
|
||||
animatorSet.play(scaleUp1).before(scaleDown2)
|
||||
animatorSet.play(scaleDown2).before(scaleUp3)
|
||||
}
|
||||
|
||||
private fun createTextAnimatorSet(animatorSet: AnimatorSet) {
|
||||
val firstAnimator = animatorSet.childAnimations[0]
|
||||
|
||||
// Fadeout in 100ms, with firstAnimator (49~51, 2 frames).
|
||||
val fadeOut = ObjectAnimator.ofFloat(
|
||||
text, "alpha",
|
||||
ANIM_TEXT_FADEOUT_FROM, ANIM_TEXT_FADEOUT_TO
|
||||
).setDuration(ANIM_TEXT_FADEOUT_DURATION)
|
||||
|
||||
// FadeIn in 66 ms, after fadeOut with delay 96ms (57~61, 4 frames).
|
||||
val fadeIn = ObjectAnimator.ofFloat(
|
||||
text, "alpha",
|
||||
ANIM_TEXT_FADEIN_FROM, ANIM_TEXT_FADEIN_TO
|
||||
).setDuration(ANIM_TEXT_FADEIN_DURATION)
|
||||
fadeIn.startDelay = (ANIM_TEXT_FADEIN_DELAY).toLong() // delay 6 frames after fadeOut
|
||||
|
||||
// Move down on y-axis, from 0 to 4.4 in 66ms, with fadeIn (57~61, 4 frames).
|
||||
val moveDown = ObjectAnimator.ofFloat(
|
||||
text, "translationY",
|
||||
ANIM_TEXT_MOVEDOWN_FROM, ANIM_TEXT_MOVEDOWN_TO
|
||||
).setDuration(ANIM_TEXT_MOVEDOWN_DURATION)
|
||||
moveDown.startDelay = (ANIM_TEXT_MOVEDOWN_DELAY) // delay 6 frames after fadeOut
|
||||
|
||||
// Move up on y-axis, from 0 to 4.4 in 66ms, after moveDown (61~69, 8 frames).
|
||||
val moveUp = ObjectAnimator.ofFloat(
|
||||
text, "translationY",
|
||||
ANIM_TEXT_MOVEUP_FROM, ANIM_TEXT_MOVEUP_TO
|
||||
).setDuration(ANIM_TEXT_MOVEUP_DURATION)
|
||||
|
||||
animatorSet.play(firstAnimator).with(fadeOut)
|
||||
animatorSet.play(fadeOut).before(fadeIn)
|
||||
animatorSet.play(fadeIn).with(moveDown)
|
||||
animatorSet.play(moveDown).before(moveUp)
|
||||
}
|
||||
|
||||
private fun formatForDisplay(count: Int): String {
|
||||
return if (count > MAX_VISIBLE_TABS) {
|
||||
SO_MANY_TABS_OPEN
|
||||
} else NumberFormat.getInstance().format(count.toLong())
|
||||
}
|
||||
|
||||
private fun adjustTextSize(newCount: Int) {
|
||||
val newRatio = if (newCount in TWO_DIGITS_TAB_COUNT_THRESHOLD..MAX_VISIBLE_TABS) {
|
||||
TWO_DIGITS_SIZE_RATIO
|
||||
} else {
|
||||
ONE_DIGIT_SIZE_RATIO
|
||||
}
|
||||
|
||||
if (newRatio != currentTextRatio) {
|
||||
currentTextRatio = newRatio
|
||||
text.viewTreeObserver.addOnGlobalLayoutListener(object :
|
||||
ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
text.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val sizeInPixel = (box.width * newRatio).toInt()
|
||||
if (sizeInPixel > 0) {
|
||||
// Only apply the size when we calculate a valid value.
|
||||
text.setTextSize(TypedValue.COMPLEX_UNIT_PX, sizeInPixel.toFloat())
|
||||
text.setTypeface(null, Typeface.BOLD)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
internal const val MAX_VISIBLE_TABS = 99
|
||||
|
||||
internal const val SO_MANY_TABS_OPEN = "∞"
|
||||
internal const val DEFAULT_TABS_COUNTER_TEXT = ":)"
|
||||
|
||||
internal const val ONE_DIGIT_SIZE_RATIO = 0.5f
|
||||
internal const val TWO_DIGITS_SIZE_RATIO = 0.4f
|
||||
internal const val TWO_DIGITS_TAB_COUNT_THRESHOLD = 10
|
||||
|
||||
// createBoxAnimatorSet
|
||||
private const val ANIM_BOX_FADEOUT_FROM = 1.0f
|
||||
private const val ANIM_BOX_FADEOUT_TO = 0.0f
|
||||
private const val ANIM_BOX_FADEOUT_DURATION = 33L
|
||||
|
||||
private const val ANIM_BOX_MOVEUP1_FROM = 0.0f
|
||||
private const val ANIM_BOX_MOVEUP1_TO = -5.3f
|
||||
private const val ANIM_BOX_MOVEUP1_DURATION = 50L
|
||||
|
||||
private const val ANIM_BOX_MOVEDOWN2_FROM = -5.3f
|
||||
private const val ANIM_BOX_MOVEDOWN2_TO = -1.0f
|
||||
private const val ANIM_BOX_MOVEDOWN2_DURATION = 167L
|
||||
|
||||
private const val ANIM_BOX_FADEIN_FROM = 0.01f
|
||||
private const val ANIM_BOX_FADEIN_TO = 1.0f
|
||||
private const val ANIM_BOX_FADEIN_DURATION = 66L
|
||||
private const val ANIM_BOX_MOVEDOWN3_FROM = -1.0f
|
||||
private const val ANIM_BOX_MOVEDOWN3_TO = 2.7f
|
||||
private const val ANIM_BOX_MOVEDOWN3_DURATION = 116L
|
||||
|
||||
private const val ANIM_BOX_MOVEDOWN4_FROM = 2.7f
|
||||
private const val ANIM_BOX_MOVEDOWN4_TO = 0.0f
|
||||
private const val ANIM_BOX_MOVEDOWN4_DURATION = 133L
|
||||
|
||||
private const val ANIM_BOX_SCALEUP1_FROM = 0.02f
|
||||
private const val ANIM_BOX_SCALEUP1_TO = 1.05f
|
||||
private const val ANIM_BOX_SCALEUP1_DURATION = 100L
|
||||
private const val ANIM_BOX_SCALEUP1_DELAY = 16L
|
||||
|
||||
private const val ANIM_BOX_SCALEDOWN2_FROM = 1.05f
|
||||
private const val ANIM_BOX_SCALEDOWN2_TO = 0.99f
|
||||
private const val ANIM_BOX_SCALEDOWN2_DURATION = 116L
|
||||
|
||||
private const val ANIM_BOX_SCALEUP3_FROM = 0.99f
|
||||
private const val ANIM_BOX_SCALEUP3_TO = 1.00f
|
||||
private const val ANIM_BOX_SCALEUP3_DURATION = 133L
|
||||
|
||||
// createTextAnimatorSet
|
||||
private const val ANIM_TEXT_FADEOUT_FROM = 1.0f
|
||||
private const val ANIM_TEXT_FADEOUT_TO = 0.0f
|
||||
private const val ANIM_TEXT_FADEOUT_DURATION = 33L
|
||||
|
||||
private const val ANIM_TEXT_FADEIN_FROM = 0.01f
|
||||
private const val ANIM_TEXT_FADEIN_TO = 1.0f
|
||||
private const val ANIM_TEXT_FADEIN_DURATION = 66L
|
||||
private const val ANIM_TEXT_FADEIN_DELAY = 16L * 6
|
||||
|
||||
private const val ANIM_TEXT_MOVEDOWN_FROM = 0.0f
|
||||
private const val ANIM_TEXT_MOVEDOWN_TO = 4.4f
|
||||
private const val ANIM_TEXT_MOVEDOWN_DURATION = 66L
|
||||
private const val ANIM_TEXT_MOVEDOWN_DELAY = 16L * 6
|
||||
|
||||
private const val ANIM_TEXT_MOVEUP_FROM = 4.4f
|
||||
private const val ANIM_TEXT_MOVEUP_TO = 0.0f
|
||||
private const val ANIM_TEXT_MOVEUP_DURATION = 66L
|
||||
}
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
/* 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
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.components.toolbar
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import mozilla.components.browser.session.Session
|
||||
import mozilla.components.browser.session.SessionManager
|
||||
import mozilla.components.concept.toolbar.Toolbar
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ext.asActivity
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* A [Toolbar.Action] implementation that shows a [TabCounter].
|
||||
*/
|
||||
class TabCounterToolbarButton(
|
||||
private val sessionManager: SessionManager,
|
||||
private val showTabs: () -> Unit
|
||||
) : Toolbar.Action {
|
||||
private var reference: WeakReference<TabCounter> = WeakReference<TabCounter>(null)
|
||||
|
||||
override fun createView(parent: ViewGroup): View {
|
||||
sessionManager.register(sessionManagerObserver)
|
||||
|
||||
val view = TabCounter(parent.context).apply {
|
||||
reference = WeakReference(this)
|
||||
setCount(
|
||||
(sessionManager.sessions
|
||||
.filter {
|
||||
(context.asActivity() as? HomeActivity)?.browsingModeManager?.isPrivate == it.private
|
||||
}).size
|
||||
)
|
||||
setOnClickListener {
|
||||
it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
showTabs.invoke()
|
||||
}
|
||||
contentDescription =
|
||||
parent.context.getString(R.string.mozac_feature_tabs_toolbar_tabs_button)
|
||||
}
|
||||
|
||||
// Set selectableItemBackgroundBorderless
|
||||
val outValue = TypedValue()
|
||||
parent.context.theme.resolveAttribute(
|
||||
android.R.attr.selectableItemBackgroundBorderless,
|
||||
outValue,
|
||||
true
|
||||
)
|
||||
view.setBackgroundResource(outValue.resourceId)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun bind(view: View) = Unit
|
||||
|
||||
private fun updateCount() {
|
||||
reference.get()?.setCountWithAnimation(sessionManager.sessions.size)
|
||||
}
|
||||
|
||||
private val sessionManagerObserver = object : SessionManager.Observer {
|
||||
override fun onSessionAdded(session: Session) {
|
||||
updateCount()
|
||||
}
|
||||
|
||||
override fun onSessionRemoved(session: Session) {
|
||||
updateCount()
|
||||
}
|
||||
|
||||
override fun onSessionsRestored() {
|
||||
updateCount()
|
||||
}
|
||||
|
||||
override fun onAllSessionsRemoved() {
|
||||
updateCount()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?browserToolbarIcons"
|
||||
android:pathData="M15.2222,22L5.7778,22C3.6914,22 2,20.3086 2,18.2222L2,8.7778C2,6.6914 3.6914,5 5.7778,5L15.2222,5C17.3086,5 19,6.6914 19,8.7778L19,18.2222C19,20.3086 17.3086,22 15.2222,22ZM5.8571,7C4.8315,7 4,7.8315 4,8.8571L4,18.1429C4,19.1685 4.8315,20 5.8571,20L15.1429,20C16.1685,20 17,19.1685 17,18.1429L17,8.8571C17,7.8315 16.1685,7 15.1429,7L5.8571,7ZM6,4C6,2.8954 6.8954,2 8,2L15.9866,2C19.3004,2 21.9866,4.6863 21.9866,8C21.9866,8.008 21.9866,8.008 21.9866,8.016L21.9632,16.0027C21.96,17.1073 21.062,18.0003 19.9573,17.9973L19.9866,8.0107L19.9866,8C19.9866,5.7909 18.1958,4 15.9866,4L6,4Z" />
|
||||
</vector>
|
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:background="@drawable/mozac_ui_tabcounter_round_rectangle_ripple"
|
||||
tools:layout_height="wrap_content"
|
||||
tools:layout_width="wrap_content">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/counter_root"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:layout_centerVertical="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/counter_box"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/mozac_ui_tabcounter_description"
|
||||
android:src="@drawable/mozac_ui_tabcounter_box"
|
||||
android:tint="?attr/browserToolbarIcons" />
|
||||
|
||||
<!-- This text size auto adjusts based on num digits in `TabCounter` -->
|
||||
<TextView
|
||||
android:id="@+id/counter_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="1dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:textColor="?attr/browserToolbarIcons"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="16" />
|
||||
</FrameLayout>
|
||||
</merge>
|
Loading…
Reference in New Issue