top of page
Writer's pictureJoseph Muller III

A Custom Tabs Side Story

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:

  1. ID: A unique Int identifying this action

  2. Icon: A 24dp x 24dp bitmap that will be displayed on the toolbar

  3. Description: A String that will be used for accessibility purposes

  4. 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

  1. The bottom toolbar color in Custom Tabs can be set using the Builder.setSecondaryToolbarColor() method

  2. 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**

  3. Action buttons will appear in the order that you add them

  4. 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:

  1. More control over the appearance of the bottom bar

  2. The ability to have unlimited action “buttons”. Every view in your RemoteViews layout can potentially trigger another action

  3. 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!

0 views0 comments

Recent Posts

See All

Comments


bottom of page