Mobile Dev Newbies Start Here
If you’re new to mobile app development, I’d recommend starting with something a little less involved. I used this book to get started with Android development:
The Legend of the Bottom Toolbar
Chrome Custom Tabs are a beautiful and convenient thing that allow you to bring all the benefits of a high performing internet browser to the confines of your mobile application with no overhead. Rather then send your users to a third party application to view web content, you can have an elegant browser running in your app with only a few lines of code. Check out my other articles on implementing and using these bad boys:
While there is plenty of documentation on customizing the appearance and behavior of the top toolbar in Custom Tabs, the bottom toolbar has been neglected by the internet community. In this article, I’ll attempt to change that.
Where is the Bottom Toolbar?
If you launch Custom Tabs without making any changes to its appearance, the only toolbar you’ll see is the top one. If you go one step further and add an action button to the toolbar, you’ll see that action appear in the upper right corner but there still won’t be a bottom toolbar.
Go ahead, add another action button. Maybe that will overflow into the bottom toolbar and –
WRONG!
The upper toolbar can only have a single custom action button and adding another will simply overwrite the original. This doesn’t mean you can’t add multiple custom actions to the top part of the Custom Tab interface, though. You are free to add up to five menu items to the the “more” menu using the Builder.addMenuItem() method.
The Old Way
The original way to add actions to the bottom toolbar was to use the Builder.addToolbarItem() method, but this method has since been deprecated. This method takes four arguments:
ID: A unique Int identifying this action
Icon: A 24dp x 24dp bitmap that will be displayed on the toolbar
Description: A String that will be used for accessibility purposes
PendingIntent: A PendingIntent that will be triggered when the action button is pressed
A simple implementation would look like this:
// Create the flag dig intent
val flagLinkIntent = Intent(main,DigBroadcastReceiver()::class.java)
flagLinkIntent.putExtra(Intent.EXTRA_SUBJECT,"This is the link you flagged")
flagLinkIntent.putExtra(Intent.EXTRA_TEXT,"Flag Dig")
val pendingFlagIntent = PendingIntent.getBroadcast(main,0,flagLinkIntent, PendingIntent.FLAG_UPDATE_CURRENT)
// Set the action button
AppCompatResources.getDrawable(main, R.drawable.flag_action)?.let {
DrawableCompat.setTint(it, Color.WHITE)
builder.addToolbarItem(1,it.toBitmap(),"Flag this link",pendingFlagIntent)
}
And the result:
Additional Details
The bottom toolbar color in Custom Tabs can be set using the Builder.setSecondaryToolbarColor() method
Y̶o̶u̶ ̶c̶a̶n̶ ̶a̶d̶d̶ ̶u̶p̶ ̶t̶o̶ ̶5̶ ̶a̶c̶t̶i̶o̶n̶ ̶b̶u̶t̶t̶o̶n̶s̶,̶ ̶w̶h̶i̶c̶h̶ ̶s̶h̶o̶u̶l̶d̶ ̶b̶e̶ ̶p̶l̶e̶n̶t̶y̶ **EDIT** This method will now only support one functional button. You can add more but they will all do the same thing. **EDIT**
Action buttons will appear in the order that you add them
The bottom toolbar will behave the same as the top toolbar on scroll. You can disable hiding like this:
customTabsIntent.intent.putExtra(CustomTabsIntent.EXTRA_ENABLE_URLBAR_HIDING, false)
The New Way
Yeah, yeah, yeah, I know. As easy as the old method is, we shouldn’t use it because it’s no longer supported. We’ll have to learn something new and more complicated…ugh.
It’s not all bad, though. Google has an example that we can use to jump start our experimentation and the new method of using RemoteViews has more benefits:
More control over the appearance of the bottom bar
The ability to have unlimited action “buttons”. Every view in your RemoteViews layout can potentially trigger another action
Real-time bottom bar updates (change colors and icons to make the interface more intuitive)
I won’t lie — the new method is appreciably more complicated and it took me a few hours to work out the kinks in my example. In the end however, the added effort was definitely worth it.
Setup
You can check out my original post on Custom Tabs for info on how to get them setup with a navigation callback but for now I’m just going to give you the code.
Dependencies
implementation ‘androidx.browser:browser:1.2.0’
Fragment code
class AdvancedTabsFragment : Fragment() {
lateinit var serviceConnection: CustomTabsServiceConnection
lateinit var client: CustomTabsClient
lateinit var session: CustomTabsSession
var builder = CustomTabsIntent.Builder()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
serviceConnection = object : CustomTabsServiceConnection() {
override fun onCustomTabsServiceConnected(name: ComponentName, mClient: CustomTabsClient) {
Log.d("Service", "Connected")
client = mClient
client.warmup(0L)
val callback = RabbitCallback()
session = mClient.newSession(callback)!!
builder.setSession(session)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d("Service", "Disconnected")
}
}
CustomTabsClient.bindCustomTabsService(requireContext(), "com.android.chrome", serviceConnection)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val root = inflater.inflate(R.layout.fragment_home, container, false)
return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
tab_button.setOnClickListener {
val url = "https://www.google.com"
//val url = "https://www.wikipedia.org"
val customTabsIntent: CustomTabsIntent = builder.build()
customTabsIntent.launchUrl(requireActivity(), Uri.parse(url))
}
}
override fun onStart() {
super.onStart()
CustomTabsClient.bindCustomTabsService(requireContext(), "com.android.chrome", serviceConnection)
}
class RabbitCallback : CustomTabsCallback() {
override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
super.onNavigationEvent(navigationEvent, extras)
Log.d("Nav", navigationEvent.toString())
when (navigationEvent) {
1 -> Log.d("Navigation", "Start") // NAVIGATION_STARTED
2 -> Log.d("Navigation", "Finished") // NAVIGATION_FINISHED
3 -> Log.d("Navigation", "Failed") // NAVIGATION_FAILED
4 -> Log.d("Navigation", "Aborted") // NAVIGATION_ABORTED
5 -> Log.d("Navigation", "Tab Shown") // TAB_SHOWN
6 -> Log.d("Navigation", "Tab Hidden") // TAB_HIDDEN
else -> Log.d("Navigation", "Else")
}
}
}
}
BroadcastReceiver
class DigBroadcastReceiver() : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val uri: Uri? = intent.data
if (uri != null) {
Log.d("Broadcast URL",uri.toString())
var toast = Toast.makeText(context, uri.toString(), Toast.LENGTH_SHORT)
val view = toast.view
view.background.setColorFilter(ContextCompat.getColor(context,R.color.red), PorterDuff.Mode.SRC_IN)
val text = view.findViewById(android.R.id.message) as TextView
text.setTextColor(ContextCompat.getColor(context, R.color.common_google_signin_btn_text_dark))
text.textAlignment = View.TEXT_ALIGNMENT_CENTER
toast.setGravity(Gravity.BOTTOM,0,200)
toast.show()
}
}
}
Manifest File
<application>
<receiver
android:name=”.ui.dig.DigBroadcastReceiver”
android:enabled=”true” />
</application>
Mastering the Bottom Toolbar
With the above setup in place, you should be all caught up and everything from here on out will be new.
Create the Bottom Toolbar Layout
Through the use of RemoteViews, the Bottom Toolbar can look almost anyway you want it to. The limitation is that it only works with a finite number of layouts (LinearLayouts being the one I’ll focus on). You can create any layout you want but mine looks like this:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/black"
android:gravity="center"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/dig_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<android.widget.ImageView
android:id="@+id/dig_action"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="4dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_shovel"
android:tint="@color/colorAccent" />
</LinearLayout>
<LinearLayout
android:id="@+id/flag_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<android.widget.ImageView
android:id="@+id/flag_action"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
android:src="@drawable/flag_action"
android:tint="@color/colorAccent" />
</LinearLayout>
</LinearLayout>
Create a Companion Object inside your BroadcastReceiver
companion object {
/**
* Creates a RemoteViews that will be shown as the bottom bar of the custom tab.
* @return The created RemoteViews instance.
*/
fun createRemoteViews(
context: Context,
digAdded: Boolean,
flagAdded: Boolean
): RemoteViews {
val remoteViews = RemoteViews(context.packageName, R.layout.dig_bottom_bar)
val digIcon: Int = if (digAdded) R.drawable.flag_action else R.drawable.dig_action
remoteViews.setImageViewResource(R.id.dig_action, digIcon)
val flagIcon: Int = if (flagAdded) R.drawable.dig_action else R.drawable.flag_action
remoteViews.setImageViewResource(R.id.flag_action, flagIcon)
return remoteViews
}
val clickableIDs = intArrayOf(R.id.dig_layout, R.id.flag_layout)
/**
* @return The PendingIntent that will be triggered when the user clicks on the Views listed by
*/
fun getPendingIntent(context: Context): PendingIntent {
val digIntent = Intent(context, DigBroadcastReceiver()::class.java)
return PendingIntent.getBroadcast(context, 0, digIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
You can find more background on Companion Objects here, but for now all you need to know is that functions and properties in a companion object are tied to the class and NOT an instance of the object. This means that processes outside of the class can access the things inside the companion object.
This companion object specifically is responsible for creating and “updating” the RemoteViews object that will represent the bottom toolbar. I put “updating” in quotes because it isn’t exactly updating the view as much as it is recreating the view based on different inputs.
This companion object is also responsible for creating a PendingIntent that will trigger the broadcast. In other words, it has a method that calls it’s parent object.
Create a prepareBottomBar() funtion
fun prepareBottombar(builder: CustomTabsIntent.Builder) {
builder.setSecondaryToolbarViews(
DigBroadcastReceiver.createRemoteViews(main, false, false),
DigBroadcastReceiver.clickableIDs,
DigBroadcastReceiver.getPendingIntent(main)
)
}
The code above should go in the host fragment. This function actually adds the bottom toolbar/RemoteViews to the CustomTabsIntent.Builder. Once it’s created, you should then call it at the end of your onCreate() method.
prepareBottomBar(builder)
Create a SessionHelper Class
object SessionHelper {
private var sCurrentSession: WeakReference<CustomTabsSession>? = null
/**
* @return The current [CustomTabsSession] object.
*/
@get:Nullable
val getCurrentSession: CustomTabsSession?
get() = if (sCurrentSession == null) null else sCurrentSession!!.get()
/**
* Sets the current session to the given one.
* @param session The current session.
*/
fun setCurrentSession(session: CustomTabsSession?) {
if(session != null) {
sCurrentSession = WeakReference(session)
}
}
}
This object helps you keep track of your session so that you can update the bottom bar. The CustomTabsSession class has a setSecondaryToolbarView() method, as well as a couple others (setActionButton and setToolbarItem) so you can make changes on the fly.
Create a getDigSession() function
fun getDigSession(): CustomTabsSession? {
if (client == null) {
session = null
} else if (session == null) {
session = client.newSession(RabbitCallback())
setCurrentSession(session)
}
return session
}
This function creates a new session if we don’t already have one and puts it in the SessionHelper so we can access it later.
Call the getDigSession()
session = getDigSession()
When the CustomTabsServiceConnection connects, call the new function to save off the session.
Handle the Intent
At this point, you’re basically done. All you have to do now is handle the intent that gets sent off when a view in the bottom toolbar is clicked. The intent will automatically contain the clicked view ID in the CustomTabsIntent.EXTRA_REMOTEVIEWS_CLICKED_ID extra. My onReceive() method in my BroadcastReceiver looks something like this.
override fun onReceive(context: Context, intent: Intent) {
val uri: Uri? = intent.data
// Bottom bar
val clickedID = intent.extras?.getInt(CustomTabsIntent.EXTRA_REMOTEVIEWS_CLICKED_ID)
val digLinks = main.tunnelViewModel.digLinks
val flagLinks = main.tunnelViewModel.flagLinks
val salientLinks = main.tunnelViewModel.salientLinks
val link = uri.toString()
var add = false // Was the link digged or flagged
var flag = false // Was the link flagged
var dig = false // Was the link added as a dig
if (uri != null) {
if (clickedID == R.id.dig_layout) {
main.utilities.genericToast(link)
// If we haven't already added this link
if (!digLinks.contains(link)) {
digLinks.add(link) // Add the flagged link to the dig links list
main.tunnelViewModel.digLinks = digLinks
main.utilities.genericToast("Link added!", R.color.colorAccent)
add = true
dig = true
} else {
digLinks.remove(link)
main.tunnelViewModel.digLinks = digLinks
main.utilities.genericToast("Link Removed!", R.color.red)
add = false
dig = false
}
} else if (clickedID == R.id.flag_layout) {
if (!flagLinks.contains(link)) {
flagLinks.add(link)
main.tunnelViewModel.flagLinks = flagLinks
main.utilities.genericToast("Link flagged!", R.color.colorAccent)
add = true
flag = true
} else {
flagLinks.remove(link)
main.tunnelViewModel.flagLinks = flagLinks
main.utilities.genericToast("Link unflagged!", R.color.red)
add = false
flag = false
}
}
// Update salient links
if (add && !salientLinks.contains(link)) {
salientLinks.add(link)
}
if (!add && salientLinks.contains(link)) {
salientLinks.remove(link)
}
main.tunnelViewModel.salientLinks = salientLinks
} else {
Log.d("Broadcast URL", "empty")
}
val session: CustomTabsSession = SessionHelper.getCurrentSession ?: return
session.setSecondaryToolbarViews(
createRemoteViews(context, dig, flag),
clickableIDs,
getPendingIntent(context)
)
}
You won’t have all of the same variables as me but the main point is that you can extract the clicked view ID and take different actions based on what was clicked. Make sure that all views you want to be clickable are included in the clickableIDs property of the companion object you created! At the end of the onReceive() method, you should reset the toolbar views using the session from the session helper if you want to make updates to the view.
In Conclusion
I know it looks like a lot but if you practice setting it up and customizing it a few times you’ll have it down pat. The RemoteViews method of implementing the bottom toolbar gives the developer a lot more flexibility over their interface so it’s worth the extra lines of code.
I’ll be adding additional information to this post as I discover it but otherwise happy coding!
And check out my app-in-progress Rabbit Hole on Google Play!
Comments