Skip to content

Commit f33abf2

Browse files
0nkoLukasPaczos
andauthored
Split Omnibar: Bottom navigation (#7032)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1207418217763355/task/1210869932265904?focus=true ### Description This PR adds the bottom navigation bar view when selected in the Appearance settings. ### Steps to test this PR - [x] Go to Settings -> Appearance - [x] Select the Split omnibar option - [x] Go back to the browser - [x] Verify the bottom navigation bar is shown - [x] Load some site - [x] Scroll up to hide the omnibar and bottom navigation bar - [x] Scroll down to show the omnibar and navigation bar - [x] Try tapping on the button to verify they work - [x] Notice the first button is a + button that opens a NTP - [x] Open a NTP - [x] Notice the first button is the a key -> Tap on it - [x] Verify it opens the password manager - [x] Tap on the menu button - [x] Verify it opens the browser menu with the navigation buttons at the bottom --------- Co-authored-by: Łukasz Paczos <lpaczos@duckduckgo.com>
1 parent 2a633d3 commit f33abf2

File tree

17 files changed

+594
-328
lines changed

17 files changed

+594
-328
lines changed

app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import com.duckduckgo.app.browser.model.BasicAuthenticationRequest
114114
import com.duckduckgo.app.browser.model.LongPressTarget
115115
import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter.QuickAccessFavorite
116116
import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter
117+
import com.duckduckgo.app.browser.omnibar.OmnibarFeatureRepository
117118
import com.duckduckgo.app.browser.omnibar.QueryOrigin.*
118119
import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender
119120
import com.duckduckgo.app.browser.remotemessage.RemoteMessagingModel
@@ -583,6 +584,7 @@ class BrowserTabViewModelTest {
583584
private val mockSubscriptionsJSHelper: SubscriptionsJSHelper = mock()
584585
private val mockOnboardingHomeScreenWidgetToggles: OnboardingHomeScreenWidgetToggles = mock()
585586
private val tabManager: TabManager = mock()
587+
private val mockOmnibarFeatureRepository: OmnibarFeatureRepository = mock()
586588

587589
private val mockAddressDisplayFormatter: AddressDisplayFormatter by lazy {
588590
mock {
@@ -846,6 +848,7 @@ class BrowserTabViewModelTest {
846848
webViewCompatWrapper = mockWebViewCompatWrapper,
847849
addressBarTrackersAnimationFeatureToggle = mockAddressBarTrackersAnimationFeatureToggle,
848850
autoconsentPixelManager = mockAutoconsentPixelManager,
851+
omnibarFeatureRepository = mockOmnibarFeatureRepository,
849852
)
850853

851854
testee.loadData("abc", null, false, false)

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import com.duckduckgo.app.browser.menu.VpnMenuStore
140140
import com.duckduckgo.app.browser.model.BasicAuthenticationCredentials
141141
import com.duckduckgo.app.browser.model.BasicAuthenticationRequest
142142
import com.duckduckgo.app.browser.model.LongPressTarget
143+
import com.duckduckgo.app.browser.navigation.bar.BrowserNavigationBarViewIntegration
143144
import com.duckduckgo.app.browser.navigation.bar.view.BrowserNavigationBarObserver
144145
import com.duckduckgo.app.browser.newtab.NewTabPageProvider
145146
import com.duckduckgo.app.browser.omnibar.Omnibar
@@ -644,6 +645,8 @@ class BrowserTabFragment :
644645

645646
private val binding: FragmentBrowserTabBinding by viewBinding()
646647

648+
private lateinit var browserNavigationBarIntegration: BrowserNavigationBarViewIntegration
649+
647650
private lateinit var omnibar: Omnibar
648651

649652
private lateinit var webViewContainer: FrameLayout
@@ -924,7 +927,7 @@ class BrowserTabFragment :
924927
}
925928

926929
InputScreenActivityResultCodes.MENU_REQUESTED -> {
927-
launchPopupMenu()
930+
launchPopupMenu(omnibarFeatureRepository.isSplitOmnibarEnabled)
928931
}
929932

930933
InputScreenActivityResultCodes.TAB_SWITCHER_REQUESTED -> {
@@ -1021,7 +1024,7 @@ class BrowserTabFragment :
10211024
omnibar = Omnibar(
10221025
omnibarType = settingsDataStore.omnibarType,
10231026
binding = binding,
1024-
isUnifiedOmnibarEnabled = omnibarFeatureRepository.isUnifiedOmnibarEnabled,
1027+
isUnifiedOmnibarEnabled = omnibarFeatureRepository.isUnifiedOmnibarFlagEnabled,
10251028
)
10261029

10271030
webViewContainer = binding.webViewContainer
@@ -1189,6 +1192,14 @@ class BrowserTabFragment :
11891192
viewModel.onNavigationBarBookmarksButtonClicked()
11901193
}
11911194
}
1195+
1196+
browserNavigationBarIntegration = BrowserNavigationBarViewIntegration(
1197+
lifecycleScope = lifecycleScope,
1198+
browserTabFragmentBinding = binding,
1199+
isEnabled = omnibarFeatureRepository.isSplitOmnibarEnabled,
1200+
omnibar = omnibar,
1201+
browserNavigationBarObserver = observer,
1202+
)
11921203
}
11931204

11941205
private fun configureEditModeChangeDetection() {
@@ -1272,6 +1283,8 @@ class BrowserTabFragment :
12721283
)
12731284
requireActivity().window.navigationBarColor = customTabToolbarColor
12741285
requireActivity().window.statusBarColor = customTabToolbarColor
1286+
1287+
browserNavigationBarIntegration.configureCustomTab()
12751288
}
12761289
}
12771290

@@ -1283,8 +1296,8 @@ class BrowserTabFragment :
12831296
private fun createPopupMenu() {
12841297
val popupMenuResourceType =
12851298
when (omnibar.omnibarType) {
1286-
OmnibarType.SINGLE_TOP, OmnibarType.SPLIT -> BrowserPopupMenu.ResourceType.TOP
1287-
OmnibarType.SINGLE_BOTTOM -> BrowserPopupMenu.ResourceType.BOTTOM
1299+
OmnibarType.SINGLE_TOP -> BrowserPopupMenu.ResourceType.TOP
1300+
OmnibarType.SINGLE_BOTTOM, OmnibarType.SPLIT -> BrowserPopupMenu.ResourceType.BOTTOM
12881301
}
12891302

12901303
popupMenu =
@@ -1409,7 +1422,7 @@ class BrowserTabFragment :
14091422
startActivity(intent)
14101423
}
14111424

1412-
private fun launchPopupMenu() {
1425+
private fun launchPopupMenu(anchorToNavigationBar: Boolean) {
14131426
val isFocusedNtp = omnibar.viewMode == ViewMode.NewTab && omnibar.getText().isEmpty() && omnibar.omnibarTextInput.hasFocus()
14141427

14151428
// small delay added to let keyboard disappear and avoid jarring transition
@@ -1424,7 +1437,12 @@ class BrowserTabFragment :
14241437
}
14251438
}
14261439

1427-
popupMenu.show(binding.rootView, omnibar.toolbar)
1440+
if (anchorToNavigationBar) {
1441+
val anchorView = browserNavigationBarIntegration.navigationBarView.popupMenuAnchor
1442+
popupMenu.showAnchoredView(requireActivity(), binding.rootView, anchorView)
1443+
} else {
1444+
popupMenu.show(binding.rootView, omnibar.toolbar)
1445+
}
14281446
viewModel.onPopupMenuLaunched()
14291447
if (isActiveCustomTab()) {
14301448
pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_OPENED)
@@ -1516,6 +1534,7 @@ class BrowserTabFragment :
15161534
webView?.removeEnableSwipeRefreshCallback()
15171535
webView?.stopNestedScroll()
15181536
webView?.stopLoading()
1537+
browserNavigationBarIntegration.onDestroyView()
15191538
super.onDestroyView()
15201539
}
15211540

@@ -1697,6 +1716,8 @@ class BrowserTabFragment :
16971716
errorView.errorLayout.gone()
16981717
sslErrorView.gone()
16991718
maliciousWarningView.gone()
1719+
1720+
browserNavigationBarIntegration.configureNewTabViewMode()
17001721
}
17011722

17021723
private fun showBrowser() {
@@ -1710,6 +1731,8 @@ class BrowserTabFragment :
17101731
sslErrorView.gone()
17111732
maliciousWarningView.gone()
17121733
omnibar.setViewMode(ViewMode.Browser(viewModel.url))
1734+
1735+
browserNavigationBarIntegration.configureBrowserViewMode()
17131736
}
17141737

17151738
private fun showError(
@@ -1731,6 +1754,8 @@ class BrowserTabFragment :
17311754
errorView.yetiIcon.setImageResource(com.duckduckgo.mobile.android.R.drawable.ic_yeti_dark)
17321755
}
17331756
errorView.errorLayout.show()
1757+
1758+
browserNavigationBarIntegration.configureBrowserViewMode()
17341759
}
17351760

17361761
private fun showMaliciousWarning(
@@ -1769,6 +1794,8 @@ class BrowserTabFragment :
17691794
isMainframe = isMainframe,
17701795
),
17711796
)
1797+
1798+
browserNavigationBarIntegration.configureBrowserViewMode()
17721799
}
17731800

17741801
private fun hideMaliciousWarning(canGoBack: Boolean) {
@@ -1843,6 +1870,8 @@ class BrowserTabFragment :
18431870
viewModel.onSSLCertificateWarningAction(action, errorResponse.url)
18441871
}
18451872
sslErrorView.show()
1873+
1874+
browserNavigationBarIntegration.configureBrowserViewMode()
18461875
}
18471876

18481877
private fun hideSSLWarning() {
@@ -2284,7 +2313,7 @@ class BrowserTabFragment :
22842313
is Command.ShowAutoconsentAnimation -> showAutoconsentAnimation(it.isCosmetic)
22852314

22862315
is Command.LaunchPopupMenu -> {
2287-
launchPopupMenu()
2316+
launchPopupMenu(it.anchorToNavigationBar)
22882317
hideKeyboard()
22892318
}
22902319

@@ -3894,7 +3923,6 @@ class BrowserTabFragment :
38943923
logcat(VERBOSE) { "Keyboard now hiding" }
38953924
hideKeyboard(omnibar.omnibarTextInput)
38963925
binding.focusDummy.requestFocus()
3897-
omnibar.showOutline(false)
38983926
}
38993927
}
39003928

@@ -3919,7 +3947,6 @@ class BrowserTabFragment :
39193947
if (!isHidden) {
39203948
logcat(VERBOSE) { "Keyboard now showing" }
39213949
showKeyboard(omnibar.omnibarTextInput)
3922-
omnibar.showOutline(true)
39233950
}
39243951
}
39253952

@@ -4538,6 +4565,8 @@ class BrowserTabFragment :
45384565
webView?.setBottomMatchingBehaviourEnabled(true) // only execute if animation is playing
45394566
}
45404567

4568+
browserNavigationBarIntegration.configureFireButtonHighlight(highlighted = viewState.fireButton.isHighlighted())
4569+
45414570
popupMenu.renderState(browserShowing, viewState, tabDisplayedInCustomTabScreen)
45424571

45434572
renderFullscreenMode(viewState)
@@ -4868,6 +4897,8 @@ class BrowserTabFragment :
48684897
omnibar.setViewMode(ViewMode.NewTab)
48694898
omnibar.isScrollingEnabled = false
48704899

4900+
browserNavigationBarIntegration.configureNewTabViewMode()
4901+
48714902
viewModel.onNewTabShown()
48724903
}
48734904

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ import com.duckduckgo.app.browser.model.BasicAuthenticationRequest
192192
import com.duckduckgo.app.browser.model.LongPressTarget
193193
import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter
194194
import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter
195+
import com.duckduckgo.app.browser.omnibar.OmnibarFeatureRepository
195196
import com.duckduckgo.app.browser.omnibar.QueryOrigin
196197
import com.duckduckgo.app.browser.omnibar.QueryOrigin.FromAutocomplete
197198
import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender
@@ -488,6 +489,7 @@ class BrowserTabViewModel @Inject constructor(
488489
private val webViewCompatWrapper: WebViewCompatWrapper,
489490
private val addressBarTrackersAnimationFeatureToggle: AddressBarTrackersAnimationFeatureToggle,
490491
private val autoconsentPixelManager: AutoconsentPixelManager,
492+
private val omnibarFeatureRepository: OmnibarFeatureRepository,
491493
) : ViewModel(),
492494
WebViewClientListener,
493495
EditSavedSiteListener,
@@ -2891,7 +2893,7 @@ class BrowserTabViewModel @Inject constructor(
28912893
showMenuButton = HighlightableButton.Visible(highlighted = false),
28922894
)
28932895
}
2894-
command.value = LaunchPopupMenu
2896+
command.value = LaunchPopupMenu(anchorToNavigationBar = !isCustomTab && omnibarFeatureRepository.isSplitOmnibarEnabled)
28952897
}
28962898

28972899
fun onPopupMenuLaunched() {

app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ sealed class Command {
479479

480480
data object CloseCustomTab : Command()
481481

482-
data object LaunchPopupMenu : Command()
482+
data class LaunchPopupMenu(val anchorToNavigationBar: Boolean) : Command()
483483

484484
data class ShowAutoconsentAnimation(
485485
val isCosmetic: Boolean,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.browser.navigation.bar
18+
19+
import com.duckduckgo.app.browser.BrowserTabFragment
20+
import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding
21+
import com.duckduckgo.app.browser.navigation.bar.view.BrowserNavigationBarObserver
22+
import com.duckduckgo.app.browser.navigation.bar.view.BrowserNavigationBarView
23+
import com.duckduckgo.app.browser.navigation.bar.view.BrowserNavigationBarView.ViewMode.Browser
24+
import com.duckduckgo.app.browser.navigation.bar.view.BrowserNavigationBarView.ViewMode.NewTab
25+
import com.duckduckgo.app.browser.omnibar.Omnibar
26+
import com.duckduckgo.common.ui.view.gone
27+
import com.duckduckgo.common.ui.view.show
28+
import com.duckduckgo.common.utils.keyboardVisibilityFlow
29+
import kotlinx.coroutines.CoroutineScope
30+
import kotlinx.coroutines.Job
31+
import kotlinx.coroutines.delay
32+
import kotlinx.coroutines.flow.distinctUntilChanged
33+
import kotlinx.coroutines.launch
34+
35+
/**
36+
* Helper class that extracts business logic that manages the [BrowserNavigationBarView] from the [BrowserTabFragment].
37+
*
38+
* The class needs to be instantiated strictly after the fragment's view has been created,
39+
* and [onDestroyView] has to be called when the the fragment's view is destroyed.
40+
* After the view is destroyed, the class becomes no-op and needs to be re-instantiated with a valid view binding.
41+
*/
42+
class BrowserNavigationBarViewIntegration(
43+
private val lifecycleScope: CoroutineScope,
44+
browserTabFragmentBinding: FragmentBrowserTabBinding,
45+
isEnabled: Boolean,
46+
private val omnibar: Omnibar,
47+
browserNavigationBarObserver: BrowserNavigationBarObserver,
48+
) {
49+
val navigationBarView: BrowserNavigationBarView = browserTabFragmentBinding.navigationBar
50+
51+
private var keyboardVisibilityObserverJob: Job? = null
52+
private var navigationBarVisibilityChangeJob: Job? = null
53+
54+
init {
55+
if (isEnabled) {
56+
onEnabled()
57+
} else {
58+
onDisabled()
59+
}
60+
navigationBarView.browserNavigationBarObserver = browserNavigationBarObserver
61+
}
62+
63+
fun configureCustomTab() {
64+
navigationBarView.setCustomTab(isCustomTab = true)
65+
}
66+
67+
fun configureBrowserViewMode() {
68+
navigationBarView.setViewMode(Browser)
69+
}
70+
71+
fun configureNewTabViewMode() {
72+
navigationBarView.setViewMode(NewTab)
73+
}
74+
75+
fun configureFireButtonHighlight(highlighted: Boolean) {
76+
navigationBarView.setFireButtonHighlight(highlighted)
77+
}
78+
79+
fun onDestroyView() {
80+
onDisabled()
81+
}
82+
83+
private fun onEnabled() {
84+
navigationBarView.show()
85+
// we're hiding the navigation bar when keyboard is shown,
86+
// to prevent it from being "pushed up" within the coordinator layout
87+
keyboardVisibilityObserverJob = lifecycleScope.launch {
88+
omnibar.textInputRootView.keyboardVisibilityFlow().distinctUntilChanged().collect { keyboardVisible ->
89+
navigationBarVisibilityChangeJob?.cancel()
90+
if (keyboardVisible) {
91+
navigationBarView.gone()
92+
} else {
93+
navigationBarVisibilityChangeJob = launch {
94+
delay(BrowserTabFragment.KEYBOARD_DELAY)
95+
navigationBarView.show()
96+
}
97+
}
98+
}
99+
}
100+
}
101+
102+
private fun onDisabled() {
103+
navigationBarView.gone()
104+
keyboardVisibilityObserverJob?.cancel()
105+
}
106+
}

0 commit comments

Comments
 (0)