Skip to content

Commit 9e9541e

Browse files
authored
Replace site picker 'Add a site' button with an animated FAB (#23122)
* Replace site picker 'Add a site' button with an animated FAB Swaps the pinned bottom bar in the site picker for a bottom-end FloatingActionButton, matching the create-content FAB used for posts and pages. Uses the Material show()/hide() scale+fade animation, a long-press tooltip, and reuses the existing add-site click handler. Removes the now-orphaned Widget.SitePicker.Button style. * Replace add-site dialog with an animated FAB menu in the site picker Tapping the site picker FAB now expands a speed-dial menu with 'Create WordPress.com site' and 'Add self-hosted site' options instead of showing the AddSiteDialog. The main FAB rotates into a close icon, a scrim dims the background, and the items stagger in/out with a translate+fade animation. Dismisses on scrim tap, back press, re-tapping the FAB, or opening search. Also lifts the FAB to match the create FAB's bottom clearance on the My Site screen, which floats above the bottom navigation bar. * Fix clipped FAB shadow in the site picker The root CoordinatorLayout clipped its children to their bounds, cutting off the FAB's elevation shadow. Set clipChildren/clipToPadding to false so the FAB and its expanded menu render their shadows fully. * Animate the site picker FAB entrance and hide it while pinning Start the FAB as invisible instead of gone so it is laid out and show() plays its entrance animation; a gone view is never laid out, so the animation was skipped. Also hide the FAB while editing pins, matching its behavior in search. * Inline the single-use toggleAddSiteMenu helper * Keep the add-site FAB hidden when its reveal is deferred during search The entrance-animation reveal is posted when the FAB isn't laid out yet. If a search was restored (e.g. after rotation) in the same pass, the earlier hide() no-ops on the still-invisible FAB and the queued show() then revealed it over the search UI. Re-check search/pin/mode state before the deferred show(). * Preserve the expanded add-site FAB menu across configuration changes Save isAddSiteMenuOpen in the instance state and, on restore, jump straight to the open end state (scrim, rotated FAB, visible items) without the entrance animation or re-firing the ADD_SITE_ALERT_DISPLAYED event. Matches the existing rotation handling for search and pin mode. * Guard add-site menu restore to DEFAULT mode only Belt-and-suspenders: the menu only exists in DEFAULT mode, so restoring the expanded state in other modes would strand the scrim/items if the invariant ever changed. * Update MySitesPage e2e for the add-site FAB menu Renaming button_add_site to fab_add_site left MySitesPage.startNewSite() referencing a nonexistent id, breaking androidTest compilation. Click the FAB, then the 'Create WordPress.com site' menu item so the flow still reaches site creation.
1 parent 44f24ca commit 9e9541e

6 files changed

Lines changed: 269 additions & 31 deletions

File tree

WordPress/src/androidTest/java/org/wordpress/android/e2e/pages/MySitesPage.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ class MySitesPage {
6363

6464
fun startNewSite() {
6565
switchSite()
66-
WPSupportUtils.clickOn(R.id.button_add_site)
66+
// the add-site FAB expands a menu; pick "Create WordPress.com site" to start creation
67+
WPSupportUtils.clickOn(R.id.fab_add_site)
68+
WPSupportUtils.clickOn(R.id.fab_wpcom)
6769
}
6870

6971
fun goToSettings() {

WordPress/src/main/java/org/wordpress/android/ui/main/ChooseSiteActivity.kt

Lines changed: 160 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.os.Bundle
66
import android.view.Menu
77
import android.view.MenuItem
88
import android.view.View
9+
import androidx.activity.addCallback
910
import androidx.activity.viewModels
1011
import androidx.appcompat.widget.SearchView
1112
import androidx.core.view.isVisible
@@ -14,6 +15,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
1415
import dagger.hilt.android.AndroidEntryPoint
1516
import org.greenrobot.eventbus.Subscribe
1617
import org.greenrobot.eventbus.ThreadMode
18+
import org.wordpress.android.BuildConfig
1719
import org.wordpress.android.R
1820
import org.wordpress.android.analytics.AnalyticsTracker
1921
import org.wordpress.android.analytics.AnalyticsTracker.Stat
@@ -26,6 +28,7 @@ import org.wordpress.android.fluxc.store.SiteStore
2628
import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged
2729
import org.wordpress.android.fluxc.store.SiteStore.OnSiteRemoved
2830
import org.wordpress.android.ui.ActivityId
31+
import org.wordpress.android.ui.ActivityLauncher
2932
import org.wordpress.android.ui.RequestCodes
3033
import org.wordpress.android.ui.mysite.SelectedSiteRepository
3134
import org.wordpress.android.ui.prefs.AppPrefsWrapper
@@ -37,6 +40,7 @@ import org.wordpress.android.util.DeviceUtils
3740
import org.wordpress.android.util.SiteUtils
3841
import org.wordpress.android.util.ToastUtils
3942
import org.wordpress.android.util.WPSwipeToRefreshHelper
43+
import org.wordpress.android.util.extensions.redirectContextClickToLongPressListener
4044
import org.wordpress.android.util.helpers.SwipeToRefreshHelper
4145
import org.wordpress.android.widgets.WPDialogSnackbar
4246
import javax.inject.Inject
@@ -52,6 +56,12 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
5256
private lateinit var menuEditPin: MenuItem
5357
private lateinit var refreshHelper: SwipeToRefreshHelper
5458
private var searchKeyword: String? = null
59+
private var isAddSiteMenuOpen = false
60+
private val addSiteMenuItems by lazy {
61+
// ordered bottom-to-top so the stagger animates upward from the main FAB
62+
listOf(binding.fabMenuItemSelfHosted, binding.fabMenuItemWpcom)
63+
}
64+
private val fabMenuItemOffset by lazy { resources.getDimension(R.dimen.margin_extra_large) }
5565

5666
@Inject
5767
lateinit var accountStore: AccountStore
@@ -78,10 +88,7 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
7888
AnalyticsTracker.track(Stat.SITE_SWITCHER_DISMISSED)
7989
finish()
8090
}
81-
binding.buttonAddSite.setOnClickListener {
82-
AnalyticsTracker.track(Stat.SITE_SWITCHER_ADD_SITE_TAPPED)
83-
AddSiteHandler.addSite(this, accountStore.hasAccessToken(), SiteCreationSource.MY_SITE)
84-
}
91+
setupAddSiteFab()
8592
binding.progress.isVisible = !appPrefsWrapper.hasFetchedSites
8693
setupRecycleView()
8794

@@ -105,6 +112,114 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
105112
viewModel.loadSites(mode)
106113
}
107114

115+
private fun setupAddSiteFab() {
116+
binding.fabAddSite.setOnClickListener {
117+
AnalyticsTracker.track(Stat.SITE_SWITCHER_ADD_SITE_TAPPED)
118+
// when the user is signed in and can add a self-hosted site there are two choices, so
119+
// expand the FAB menu; otherwise there's only one action, so trigger it directly
120+
if (accountStore.hasAccessToken() && BuildConfig.ENABLE_ADD_SELF_HOSTED_SITE) {
121+
if (isAddSiteMenuOpen) closeAddSiteMenu() else openAddSiteMenu()
122+
} else {
123+
AddSiteHandler.addSite(this, accountStore.hasAccessToken(), SiteCreationSource.MY_SITE)
124+
}
125+
}
126+
binding.fabAddSite.setOnLongClickListener {
127+
ToastUtils.showToast(this, R.string.site_picker_add_a_site, ToastUtils.Duration.SHORT)
128+
true
129+
}
130+
binding.fabAddSite.redirectContextClickToLongPressListener()
131+
132+
binding.fabMenuScrim.setOnClickListener { closeAddSiteMenu() }
133+
val createWpcomSite = {
134+
closeAddSiteMenu()
135+
ActivityLauncher.newBlogForResult(this, SiteCreationSource.MY_SITE)
136+
}
137+
val addSelfHostedSite = {
138+
closeAddSiteMenu()
139+
ActivityLauncher.addSelfHostedSiteForResult(this)
140+
}
141+
binding.fabWpcom.setOnClickListener { createWpcomSite() }
142+
binding.fabMenuItemWpcom.setOnClickListener { createWpcomSite() }
143+
binding.fabSelfHosted.setOnClickListener { addSelfHostedSite() }
144+
binding.fabMenuItemSelfHosted.setOnClickListener { addSelfHostedSite() }
145+
146+
onBackPressedDispatcher.addCallback(this) {
147+
if (isAddSiteMenuOpen) {
148+
closeAddSiteMenu()
149+
} else {
150+
isEnabled = false
151+
onBackPressedDispatcher.onBackPressed()
152+
}
153+
}
154+
}
155+
156+
private fun openAddSiteMenu() {
157+
if (isAddSiteMenuOpen) return
158+
isAddSiteMenuOpen = true
159+
AnalyticsTracker.track(
160+
Stat.ADD_SITE_ALERT_DISPLAYED,
161+
mapOf(KEY_SOURCE to SiteCreationSource.MY_SITE.label)
162+
)
163+
164+
binding.fabMenuScrim.animate().cancel()
165+
binding.fabMenuScrim.isVisible = true
166+
binding.fabMenuScrim.animate().alpha(SCRIM_ALPHA).setDuration(FAB_MENU_ANIM_DURATION).start()
167+
binding.fabAddSite.animate().rotation(FAB_ICON_ROTATION).setDuration(FAB_MENU_ANIM_DURATION).start()
168+
169+
addSiteMenuItems.forEachIndexed { index, item ->
170+
item.animate().cancel()
171+
item.alpha = 0f
172+
item.translationY = fabMenuItemOffset
173+
item.isVisible = true
174+
item.animate()
175+
.alpha(1f)
176+
.translationY(0f)
177+
.setStartDelay(index * FAB_MENU_STAGGER)
178+
.setDuration(FAB_MENU_ANIM_DURATION)
179+
.withEndAction(null)
180+
.start()
181+
}
182+
}
183+
184+
private fun closeAddSiteMenu() {
185+
if (!isAddSiteMenuOpen) return
186+
isAddSiteMenuOpen = false
187+
188+
binding.fabMenuScrim.animate().cancel()
189+
binding.fabMenuScrim.animate().alpha(0f).setDuration(FAB_MENU_ANIM_DURATION)
190+
.withEndAction { binding.fabMenuScrim.isVisible = false }.start()
191+
binding.fabAddSite.animate().rotation(0f).setDuration(FAB_MENU_ANIM_DURATION).start()
192+
193+
addSiteMenuItems.forEachIndexed { index, item ->
194+
item.animate().cancel()
195+
item.animate()
196+
.alpha(0f)
197+
.translationY(fabMenuItemOffset)
198+
.setStartDelay(index * FAB_MENU_STAGGER)
199+
.setDuration(FAB_MENU_ANIM_DURATION)
200+
.withEndAction { item.isVisible = false }
201+
.start()
202+
}
203+
}
204+
205+
/**
206+
* Restores the expanded menu after a configuration change by jumping straight to the open
207+
* end state — no entrance animation and no analytics, both of which belong to a user-initiated
208+
* open. Setting the FAB visible directly (rather than via show()) skips its scale animation.
209+
*/
210+
private fun expandAddSiteMenuInstantly() {
211+
isAddSiteMenuOpen = true
212+
binding.fabAddSite.isVisible = true
213+
binding.fabAddSite.rotation = FAB_ICON_ROTATION
214+
binding.fabMenuScrim.isVisible = true
215+
binding.fabMenuScrim.alpha = SCRIM_ALPHA
216+
addSiteMenuItems.forEach { item ->
217+
item.isVisible = true
218+
item.alpha = 1f
219+
item.translationY = 0f
220+
}
221+
}
222+
108223
override fun onStart() {
109224
super.onStart()
110225
dispatcher.register(this)
@@ -172,19 +287,40 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
172287
private fun setupMenuVisibility() {
173288
if (mode == SitePickerMode.DEFAULT) {
174289
menuEditPin.isVisible = true
175-
binding.layoutAddSite.isVisible = true
290+
// hide the FAB while editing pins, otherwise reveal it
291+
if (adapter.mode == ActionMode.Pin) {
292+
binding.fabAddSite.hide()
293+
} else {
294+
showAddSiteFab()
295+
}
176296
} else {
177297
menuEditPin.isVisible = false
178-
binding.layoutAddSite.isVisible = false
298+
binding.fabAddSite.hide()
179299
}
180300
}
181301

302+
private fun showAddSiteFab() {
303+
val fab = binding.fabAddSite
304+
// FloatingActionButton.show() only plays its entrance animation once the view is laid out.
305+
// setupMenuVisibility() runs from onPrepareOptionsMenu during the first layout pass, before
306+
// the FAB is laid out, so post the initial reveal to guarantee the animation. Re-check state
307+
// when the posted reveal runs: a search may have been expanded in the meantime (e.g. a search
308+
// restored after rotation), in which case the FAB must stay hidden.
309+
if (fab.isLaidOut) fab.show() else fab.post { if (shouldShowAddSiteFab()) fab.show() }
310+
}
311+
312+
private fun shouldShowAddSiteFab(): Boolean {
313+
val searchExpanded = ::menuSearch.isInitialized && menuSearch.isActionViewExpanded
314+
return mode == SitePickerMode.DEFAULT && adapter.mode != ActionMode.Pin && !searchExpanded
315+
}
316+
182317
private fun setupSearchView() {
183318
val searchView = menuSearch.actionView as SearchView
184319
searchView.maxWidth = Integer.MAX_VALUE
185320
menuSearch.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
186321
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
187-
binding.layoutAddSite.isVisible = false
322+
closeAddSiteMenu()
323+
binding.fabAddSite.hide()
188324
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
189325
override fun onQueryTextSubmit(query: String): Boolean {
190326
if (!DeviceUtils.getInstance().hasHardwareKeyboard(this@ChooseSiteActivity)) {
@@ -255,6 +391,8 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
255391
menuEditPin.setIcon(null)
256392
menuEditPin.title = getString(R.string.label_done_button)
257393
adapter.setActionMode(ActionMode.Pin)
394+
closeAddSiteMenu()
395+
binding.fabAddSite.hide()
258396
}
259397

260398
/**
@@ -265,6 +403,7 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
265403
menuEditPin.setIcon(R.drawable.pin_filled)
266404
menuEditPin.title = getString(R.string.site_picker_edit_pins)
267405
adapter.setActionMode(ActionMode.None)
406+
showAddSiteFab()
268407
}
269408

270409
private fun setupRecycleView() {
@@ -351,6 +490,7 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
351490
super.onSaveInstanceState(outState)
352491
outState.putString(KEY_SEARCH_KEYWORD, searchKeyword)
353492
outState.putString(KEY_ACTION_MODE, adapter.mode.value)
493+
outState.putBoolean(KEY_ADD_SITE_MENU_OPEN, isAddSiteMenuOpen)
354494
}
355495

356496
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -361,6 +501,14 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
361501
savedInstanceState.getString(KEY_ACTION_MODE)?.let { actionMode ->
362502
adapter.setActionMode(ActionMode.from(actionMode))
363503
}
504+
505+
// restore the expanded add-site menu (the menu only exists in DEFAULT mode and can't be
506+
// open while pinning)
507+
if (savedInstanceState.getBoolean(KEY_ADD_SITE_MENU_OPEN) &&
508+
mode == SitePickerMode.DEFAULT && adapter.mode != ActionMode.Pin
509+
) {
510+
expandAddSiteMenuInstantly()
511+
}
364512
}
365513

366514
companion object {
@@ -372,10 +520,15 @@ class ChooseSiteActivity : BaseAppCompatActivity() {
372520
const val KEY_SITE_CREATED_BUT_NOT_FETCHED = "key_site_created_but_not_fetched"
373521
const val KEY_SEARCH_KEYWORD = "key_search_keyword"
374522
const val KEY_ACTION_MODE = "key_action_mode"
523+
const val KEY_ADD_SITE_MENU_OPEN = "key_add_site_menu_open"
375524
private const val TRACK_PROPERTY_STATE = "state"
376525
private const val TRACK_PROPERTY_STATE_EDIT = "edit"
377526
private const val TRACK_PROPERTY_STATE_DONE = "done"
378527
private const val TRACK_PROPERTY_SECTION = "section"
528+
private const val SCRIM_ALPHA = 0.4f
529+
private const val FAB_ICON_ROTATION = 45f
530+
private const val FAB_MENU_ANIM_DURATION = 200L
531+
private const val FAB_MENU_STAGGER = 40L
379532

380533
@JvmStatic
381534
var isRunning = false
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape
3+
xmlns:android="http://schemas.android.com/apk/res/android"
4+
android:shape="rectangle">
5+
<solid android:color="?attr/colorSurface" />
6+
<corners android:radius="@dimen/margin_small" />
7+
</shape>

0 commit comments

Comments
 (0)