The browser controls system provides a way to move the core browser UI synchronously with the changes in the web content size and offset. The primary goal of this system is to maximize the web content the user can see by hiding the UI components smoothly when the user is scrolling through the website and showing them smoothly when the user needs them again. The interactions of the Java Android views (the core UI) with the web content are accommodated by this system. The browser controls system is dynamic, and it interacts with the web contents to position the controls. This is one of the reasons why these seemingly simple browser controls are complicated and difficult to work with.
There are two components to the browser controls: an Android view that handles the user interaction and a composited texture that is used for scrolling. When the browser controls are fully visible on the screen, what the user sees is an Android view, but the moment the user starts scrolling down on the web page, the Android views are hidden (i.e. visibility set to View#INVISIBLE), exposing the visually identical composited texture behind it. The composited texture follows the web contents throughout the scrolling gesture and can be scrolled off the screen completely.
The browser controls need to be in sync with the web contents when scrolling. Android views and web contents are drawn on different surfaces that are updated at different times. So, even if we were to observe the web contents and update the Android view positions accordingly (i.e. View#setTranslationY()), the browser controls would still be out of sync with the web contents. Android 10 provides a SurfaceControl API that might solve this issue without having to use composited textures, but this is unlikely to be adopted anytime soon since it’s only available on Q+.
There are several classes that are used to draw composited textures:
The Android view is wrapped in a ViewResourceFrameLayout
. If we look at bottom_control_container.xml, the xml layout for the bottom controls, the views are wrapped in ScrollingBottomViewResourceFrameLayout.
A scene layer (ScrollingBottomViewSceneLayer and its native counterpart scrolling_bottom_view_scene_layer.cc in this example) is responsible for creating a compositor layer using the view resource. LayoutManager adds the scene layer to the global layout.
See these example CLs adding a scene layer and adding the Android view to use as a resource.
There are two paths involved:
Browser controls properties:
Browser controls state/positioning information:
BrowserControlsManager is the class that manages the browser controls properties and responds to the browser controls related information that comes from the renderer process. It provides an interface, BrowserControlsStateProvider, that can be used to get/set browser controls properties. BrowserControlsStateProvider in addition provides an Observer interface for observing changes in browser controls properties and offsets.
There are 2 classes that are responsible for calculating the browser controls offsets in the renderer: cc::BrowserControlsOffsetManager and blink::BrowserControls. These two classes are almost identical in behavior except for some small differences:
Both classes calculate browser controls offsets during scroll events. Both classes have functions ScrollBegin(), ScrollBy(), and ScrollEnd() that notify the class of the changes in scrolling state. The biggest difference here is that cc::BrowserControlsOffsetManager calculates the offsets when the scroll happens on the cc thread (or impl thread, more on that in this doc) while blink::BrowserControls calculates the offsets for main thread scrolling. Today, most of the scrolling happens on the impl thread and the main thread scrolling is used only as a fallback.
Animations are only handled by cc::BrowserControlsOffsetManager
. Animation in this context means changing the browser controls offsets gradually without user action. There are two types of animations that are handled by this class:
Any discrepancy between the values from the cc and the blink versions of the browser controls classes are resolved using SyncedProperty.
cc::BrowserControlsOffsetManager
has 3 functions responsible for handling the scroll gestures: ::ScrollBegin(), ::ScrollBy(), and ::ScrollEnd()
. ::ScrollBy()
is called with the pending scroll delta during the scroll and calculates how much of the browser controls should be visible after this scroll event. This class uses “shown ratio” instead of offsets (though it should probably be raw pixel offsets). In normal operation, the shown ratio is in the range [0, 1], 0 being completely hidden and 1 being completely visible. The only time it will be outside of this range is when computing height-change animations. Also, if the min-height is set to a value larger than 0, the lower end of this range will be updated accordingly. This is called ‘MinShownRatio’ in code and is equal to MinHeight / ControlsHeight
.
A shown ratio is calculated for the top and bottom controls separately. Even though the top and bottom controls are currently connected, they don't have to share the same shown ratio (especially true with min height animations). The shown ratios that are calculated in the renderer process are sent to the browser process in a RenderFrameMetadata struct, alongside the controls heights and min-height offsets.
These ratios are used to calculate the visible height and the controls offsets in the browser process so that the composited texture for the browser controls is displayed in the right place.
Path for the top controls height (other properties follow a similar one):
BrowserControlsSizer#setTopControlsHeight()
(Implemented in BrowserControlsManager.java)BrowserControlsStateProvider#Observer#onTopControlsHeightChanged()
(Implemented in CompositorViewHolder.java)WebContents#notifyBrowserControlsHeightChanged()
(Implemented in WebContentsImpl.java)WebContentsAndroid::NotifyBrowserControlsHeightChanged()
(Native)ViewAndroid::OnBrowserControlsHeightChanged()
EventHandlerAndroid::OnBrowserControlsHeightChanged()
(Implemented in web_contents_view_android.cc)RenderWidgetHostViewAndroid::SynchronizeVisualProperties()
RenderWidgetHostImpl::SynchronizeVisualProperties()
RenderWidgetHostImpl::GetVisualProperties()
to fill the blink::VisualProperties
struct. Browser controls properties are put in the cc::BrowserControlsParams
structRenderViewHostDelegateView::GetTopControlsHeight()
(implemented in web_contents_view_android.cc)WebContentsDelegate::GetTopControlsHeight()
(implemented in web_contents_delegate_android.cc)WebContentsDelegateAndroid#getTopControlsHeight()
(Java, implemented in ActivityTabWebContentsDelegateAndroid.java)BrowserControlsStateProvider#getTopControlsHeight()
(implemented in BrowserControlsManager.java)BrowserControlsManager
is the source of truth for the browser controls propertiesblink::mojom::Widget::UpdateVisualProperties() // blink_widget_
with the filled VisualProperties
to send the properties to the renderer.WidgetBase
in blink receives the message (through the UpdateVisualProperties
override of the widget mojo interface).LayerTreeHost::SetBrowserControlsParams()
(this is for the impl thread)LayerTreeHost
caches the BrowserControlsParams
then calls SetNeedsCommit()
.LayerTreeImpl::SetBrowserControlsParams
is called, BrowserControlsOffsetManager::OnBrowserControlsParamsChanged()
is called as a result.BrowserControlsOffsetManager
then updates the shown ratios and/or sets up animations depending on the new and old properties.WidgetBaseClient::UpdateVisualProperties()
(this is for the main thread)WebWidgetClient
implemented in render_widget.ccRenderWidget::ResizeWebWidget()
RenderWidgetDelegate::ResizeWebWidgetForWidget()
implemented in render_view_impl.ccWebView::ResizeWithBrowserControls()
implemented in web_view_impl.ccblink::BrowserControls::SetParams()
Example CL that wires the browser controls params
Path for the top controls shown ratio (other ratios, heights and offsets follow a similar one):
LayerTreeHostImpl::MakeRenderFrameMetadata()
is called to get the filled RenderFrameMetadata
struct.LayerTreeHostImpl
then calls RenderFrameMetadataObserver::OnRenderFrameSubmission()
(implemented in render_frame_metadata_observer_impl.cc) with the filled RenderFrameMetadata
structmojom::RenderFrameMetadataObserverClient::OnRenderFrameMetadataChanged
to send the metadata to the browserRenderFrameMetadataProviderImpl
receives the message (through the OnRenderFrameMetadataChanged
override of the RenderFrameMetadataObserverClient
mojo interface)RenderFrameMetadataProvider::Observer::OnRenderFrameMetadataChangedBeforeActivation
overridden by RenderWidgetHostViewBase
and implemented in render_widget_host_view_android.ccRenderWidgetHostViewAndroid::UpdateControls
ViewAndroid::OnTopControlsChanged()
with the calculated offsetsViewAndroidDelegate#onTopControlsChanged()
(Java) implemented in TabViewAndroidDelegate.javaTabBrowserControlsOffsetHelper#setTopOffset()
TabObserver#onBrowserControlsOffsetChanged()
implemented in BrowserControlsManager.javaExample CL that wires the browser controls min-height offsets
This is a new fast path for scrolling/animating browser controls that made scrolling more responsive and less janky, and launched in June 2025. A high level description of individual components/items are provided first. A detailed sequence of events that happen during scrolls and animations will follow.
Capturing refers to converting an android view to a bitmap. While scrolling, parts of the composited UI pull resources from the captured bitmap, which determines what that layer draws to the screen. This means we must have a capture of the most recent state of the browser controls before scrolling. Otherwise, the screen might display an older version of the UI (ex. the toolbar could show an old url instead of the current one) or a blank layer (in the case where there is no capture.)
Currently, there are cases where capturing is initiated by scrolling. But captures can be slow, so this could cause a noticeable delay in the start of the scroll. Capturing also causes a browser frame to be submitted, which doesn’t align with the goal of BCIV. To fix this problem, we capture at an earlier point in time. For the following reasons, we decide to capture when page load finishes:
SurfaceSync is a mechanism used during scrolling to ensure that both the browser and renderer frames are received before attempting to draw. The browser updates the positions of the browser controls, while the renderer updates the position of the content layer. So we need SurfaceSync to make sure the controls and content layer move together. But when scrolling with BCIV, there won’t be a browser frame anymore, so we don’t trigger SurfaceSync in this case. For animations, SurfaceSync is sometimes still needed (see Stack trace for animations section below for more details.)
An OffsetTag is a wrapper for an UnguessableToken. We tag a slim::Layer with an OffsetTag to indicate that viz is able to move it.
An OffsetTagValue associates an OffsetTag with an offset, which represents how far the layers with this tag should be offset by.
Tags need to be distributed to 3 areas:
Currently, we have 3 OffsetTags, one tag for each group: top controls, content layer, bottom controls. As mentioned, the content layer and top controls move in the opposite direction as bottom controls, so they need to have different tags. However, due to the toolbar’s shadow, the top controls and content layer must also have a different tag. The shadow is a visual effect that is z-indexed on top of the content layer. When the toolbar is moved off screen, the shadow needs to immediately disappear. Prior to BCIV, the visibility of the shadow’s layer was toggled, but this would incur a browser frame. To accomplish this with BCIV, we just offset the toolbar additionally by the shadow’s height when it goes off screen (this is done with the bottom controls as well, since it also has a shadow.)
The existence/absence of an OffsetTag on a layer depends on whether that layer is moveable during a scroll/animation. The browser process controls whether or not the browser controls can be scrolled via BrowserControlsVisibilityDelegate. This is an ObservableSupplier that can be set with a BrowserControlsState value:
These updates are done in TabBrowserControlsConstraintsHelper when the visibility constraint changes.
We also update the tags when the tab becomes hidden/shown. This is because there are times where the BrowserControlsState changes while the tab is hidden/uninteractable (ex. when the grid tab switcher is overlaid above the UI) which results in the TabBrowserControlsConstraintsHelper not getting notified. If we don’t update the tags, the controls could either remain unscrollable, or become scrollable when it’s not supposed to be, which is a security concern.
An OffsetTagConstraint contains a min and max for both x and y directions, which represents the valid range the layers can move in. If an OffsetTagValue indicates to move a layer outside of the valid range, the position of the layer will be clamped to the range’s boundary.
While scrolling, the constraints for each layer will be set to allow the layer to be positioned anywhere between its min_height and height. Nothing special here, this just means the layer is allowed to move freely within its scrollable range (from being completely visible to completely off screen.)
When there’s an animation caused by a height change, the offsets from the renderer are calculated based on the new height. In certain cases, the constraints must allow for the layer to move past its scrollable range. (For example, consider when the top toolbar is fully visible and a height decrease is being animated. The new height is smaller than the old height, so throughout the animation, the toolbar is positioned past its fully visible position.) Constraints are updated when height changes (this includes when the toolbar changes its position.)
These assumptions must hold before browser controls become scrollable/animatable:
Currently, only the top+bottom controls and content layer are moved by BCIV because they are usually always present during a scroll/animation. If there is a new/existing browser control that appears frequently and needs to be scrolled/animated, it should be moved with BCIV.
Here are some example CLs for the bottom tabgroup strip that adds and distributes OffsetTag related objects, and makes it move with BCIV and prevent other sources from unnecessarily submitting frames.
When there is no web contents the browser controls can/should interact with, the browser controls will always be fully shown. This is currently the case for native pages such as NTP and other surfaces such as the tab switcher. However, the browser controls heights can still change, even without web contents. In this case, the transition animations are run by BrowserControlsManager
in the browser process. See BrowserControlsManager.java. BrowserControlsManager
animates the height changes by updating the controls offsets and the content offset over time. For every frame, BrowserControlsStateProvider#Observer
s are notified through #onControlsOffsetChanged()
.
BrowserControlsStateProvider
interface will be the best way most of the time.BrowserControlsStateProvider#Observer
interface can be used for this purpose. Also, a height change is very likely to be followed by content/controls offset updates over time. In this case, basing any margin/padding/offset updates on Observer#onTopControlsHeightChanged
calls might cause a jump instead of a smooth animation.