Swipe spaces bottom bar

Change-Id: Ib15b3b28f5f429e73cf80130dc575ae2111a1cef
This commit is contained in:
SpiritCroc 2022-10-08 14:49:00 +02:00
parent 3b6821d627
commit 43d77f553e
7 changed files with 433 additions and 5 deletions

View File

@ -25,6 +25,7 @@ import android.view.ViewGroup
import android.view.ViewTreeObserver
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.airbnb.mvrx.UniqueOnly
@ -60,6 +61,8 @@ import im.vector.app.features.home.room.list.RoomListFragment
import im.vector.app.features.home.room.list.RoomListParams
import im.vector.app.features.home.room.list.RoomListSectionBuilder.Companion.SPACE_ID_FOLLOW_APP
import im.vector.app.features.home.room.list.UnreadCounterBadgeView
import im.vector.app.features.home.room.list.home.spacebar.SpaceBarController
import im.vector.app.features.home.room.list.home.spacebar.SpaceBarData
import im.vector.app.features.popup.PopupAlertManager
import im.vector.app.features.popup.VerificationVectorAlert
import im.vector.app.features.settings.VectorLocaleProvider
@ -69,6 +72,7 @@ import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.workers.signout.BannerState
import im.vector.app.features.workers.signout.ServerBackupStatusAction
import im.vector.app.features.workers.signout.ServerBackupStatusViewModel
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import timber.log.Timber
@ -90,6 +94,7 @@ class HomeDetailFragment :
@Inject lateinit var vectorPreferences: VectorPreferences
@Inject lateinit var spaceStateHandler: SpaceStateHandler
@Inject lateinit var vectorLocale: VectorLocaleProvider
@Inject lateinit var spaceBarController: SpaceBarController
private val DEBUG_VIEW_PAGER = DbgUtil.isDbgEnabled(DbgUtil.DBG_VIEW_PAGER)
private val viewPagerDimber = Dimber("Home pager", DbgUtil.DBG_VIEW_PAGER)
@ -145,6 +150,16 @@ class HomeDetailFragment :
private val currentCallsViewPresenter = CurrentCallsViewPresenter()
private val spaceBarListener = object: SpaceBarController.SpaceBarListener {
override fun onSpaceBarSelectSpace(space: RoomSummary?) {
spaceStateHandler.setCurrentSpace(space?.roomId, from = SelectSpaceFrom.SELECT)
}
override fun onSpaceBarLongPressSpace(space: RoomSummary?): Boolean {
sharedActionViewModel.post(HomeActivitySharedAction.OpenDrawer)
return true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java)
@ -156,6 +171,12 @@ class HomeDetailFragment :
checkNotificationTabStatus()
val spaceBarAdapter = spaceBarController.also { controller ->
controller.spaceRoomListener = spaceBarListener
}.adapter
views.spaceBarRecyclerView.layoutManager = LinearLayoutManager(context)
views.spaceBarRecyclerView.adapter = spaceBarAdapter
// Reduce sensitivity of viewpager to avoid scrolling horizontally by accident too easily
views.roomListContainerPager.reduceDragSensitivity(4)
@ -226,7 +247,9 @@ class HomeDetailFragment :
highlighted = state.otherSpacesUnread.isHighlight,
unread = state.otherSpacesUnread.unreadCount,
markedUnread = false
)
).also {
spaceBarController.submitHomeUnreadCounts(it)
}
)
}
@ -280,6 +303,9 @@ class HomeDetailFragment :
override fun onDestroyView() {
currentCallsViewPresenter.unBind()
spaceBarController.spaceRoomListener = null
super.onDestroyView()
}
@ -433,6 +459,9 @@ class HomeDetailFragment :
}
private fun onSpaceChange(spaceSummary: RoomSummary?) {
if (pagerPagingEnabled) {
spaceBarController.selectSpace(spaceSummary)
}
if (spaceSummary == null) {
views.groupToolbarSpaceTitleView.isVisible = false
} else {
@ -441,6 +470,11 @@ class HomeDetailFragment :
}
}
private fun setCurrentPagerItem(index: Int, smoothScroll: Boolean) {
views.roomListContainerPager.setCurrentItem(index, smoothScroll)
spaceBarController.scrollToSpacePosition(index)
}
private fun setupKeysBackupBanner() {
serverBackupStatusViewModel.handle(ServerBackupStatusAction.OnBannerDisplayed)
serverBackupStatusViewModel
@ -556,6 +590,12 @@ class HomeDetailFragment :
getPageIndexForSpaceId(selectedSpaceId, unsafeSpaces)
}
val pagingEnabled = pagingAllowed && unsafeSpaces.isNotEmpty() && selectedIndex != null
if (pagingEnabled) {
views.spaceBarRecyclerView.isVisible = true
spaceBarController.submitData(SpaceBarData(spaces?.let { listOf<RoomSummary?>(null) + it }, selectedSpace))
} else {
views.spaceBarRecyclerView.isVisible = false
}
val safeSpaces = if (pagingEnabled) unsafeSpaces else listOf()
// Check if we need to recreate the adapter for a new tab
if (oldAdapter != null) {
@ -582,7 +622,7 @@ class HomeDetailFragment :
// Do not smooth scroll large distances to avoid loading unnecessary many room lists
val diff = selectedIndex - views.roomListContainerPager.currentItem
val smoothScroll = abs(diff) <= 1
views.roomListContainerPager.setCurrentItem(selectedIndex, smoothScroll)
setCurrentPagerItem(selectedIndex, smoothScroll)
}
}
return
@ -656,7 +696,7 @@ class HomeDetailFragment :
}
try {
viewPagerDimber.i{"Home pager: set initial page $selectedIndex"}
views.roomListContainerPager.setCurrentItem(selectedIndex ?: 0, false)
setCurrentPagerItem(selectedIndex ?: 0, false)
initialPageSelected = true
} catch (e: Exception) {
Timber.e("Home pager: Could not set initial page after creating adapter: $e")

View File

@ -0,0 +1,157 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2022 SpiritCroc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list.home.spacebar
import androidx.recyclerview.widget.LinearLayoutManager
import com.airbnb.epoxy.Carousel
import com.airbnb.epoxy.CarouselModelBuilder
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.carousel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.list.UnreadCounterBadgeView
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
class SpaceBarController @Inject constructor(
val stringProvider: StringProvider,
private val avatarRenderer: AvatarRenderer,
) : EpoxyController() {
private var data: SpaceBarData = SpaceBarData()
private var topLevelUnreadCounts: UnreadCounterBadgeView.State.Count = UnreadCounterBadgeView.State.Count(0, false, 0, false)
interface SpaceBarListener {
fun onSpaceBarSelectSpace(space: RoomSummary?)
fun onSpaceBarLongPressSpace(space: RoomSummary?): Boolean
}
var spaceRoomListener: SpaceBarListener? = null
private var carousel: Carousel? = null
override fun buildModels() {
val host = this
data.spaces?.let {
addSpaces(host, it, data.selectedSpace)
}
}
private fun addSpaces(host: SpaceBarController, spaces: List<RoomSummary?>, selectedSpace: RoomSummary?) {
carousel {
id("spaces_carousel")
padding(
Carousel.Padding(
0,
0,
0,
0,
0,
)
)
onBind { _, view, _ ->
host.carousel = view
host.scrollToSpace(selectedSpace?.roomId)
}
onUnbind { _, _ ->
host.carousel = null
}
withModelsFrom(spaces) { spaceSummary ->
val onClick = host.spaceRoomListener?.let { it::onSpaceBarSelectSpace }
val onLongClick = host.spaceRoomListener?.let { it::onSpaceBarLongPressSpace }
if (spaceSummary == null) {
SpaceBarItem_()
.id("de.spiritcroc.riotx.spacebarhome")
.avatarRenderer(host.avatarRenderer)
.matrixItem(null)
.unreadNotificationCount(host.topLevelUnreadCounts.count)
.showHighlighted(host.topLevelUnreadCounts.highlighted)
.unreadCount(host.topLevelUnreadCounts.unread)
.markedUnread(host.topLevelUnreadCounts.markedUnread)
.selected(selectedSpace == null)
.itemLongClickListener { _ -> onLongClick?.invoke(null) ?: false }
.itemClickListener { onClick?.invoke(null) }
} else {
SpaceBarItem_()
.id(spaceSummary.roomId)
.avatarRenderer(host.avatarRenderer)
.matrixItem(spaceSummary.toMatrixItem())
.unreadNotificationCount(spaceSummary.notificationCount)
.showHighlighted(spaceSummary.highlightCount > 0)
.unreadCount(spaceSummary.unreadCount ?: 0)
.markedUnread(spaceSummary.markedUnread)
.selected(spaceSummary.roomId == selectedSpace?.roomId)
.itemLongClickListener { _ -> onLongClick?.invoke(spaceSummary) ?: false }
.itemClickListener { onClick?.invoke(spaceSummary) }
}
}
}
}
fun submitData(data: SpaceBarData) {
this.data = data
requestModelBuild()
}
fun submitHomeUnreadCounts(counts: UnreadCounterBadgeView.State.Count) {
this.topLevelUnreadCounts = counts
requestModelBuild()
}
fun selectSpace(space: RoomSummary?) {
this.data = this.data.copy(selectedSpace = space)
requestModelBuild()
scrollToSpace(space?.roomId)
}
fun scrollToSpace(spaceId: String?) {
val position = this.data.spaces?.indexOfFirst { spaceId == it?.roomId } ?: -1
if (position >= 0) {
scrollToSpacePosition(position)
}
}
fun scrollToSpacePosition(position: Int) {
val safeCarousel = carousel ?: return
var effectivePosition = position
val lm = safeCarousel.layoutManager as? LinearLayoutManager
if (lm != null) {
// Look-ahead of 1
if (lm.findFirstCompletelyVisibleItemPosition() >= position) {
effectivePosition--
} else if (lm.findLastVisibleItemPosition() <= position) {
effectivePosition++
}
effectivePosition = max(0, min(effectivePosition, lm.itemCount-1))
}
safeCarousel.smoothScrollToPosition(effectivePosition)
}
}
private inline fun <T> CarouselModelBuilder.withModelsFrom(
items: List<T>,
modelBuilder: (T) -> EpoxyModel<*>
) {
models(items.map { modelBuilder(it) })
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2022 SpiritCroc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list.home.spacebar
import org.matrix.android.sdk.api.session.room.model.RoomSummary
data class SpaceBarData(
val spaces: List<RoomSummary?>? = null,
val selectedSpace: RoomSummary? = null,
)

View File

@ -0,0 +1,91 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2022 SpiritCroc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.list.home.spacebar
import android.content.res.ColorStateList
import android.view.HapticFeedbackConstants
import android.view.View
import android.widget.ImageView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.platform.CheckableConstraintLayout
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.list.UnreadCounterBadgeView
import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.util.MatrixItem
@EpoxyModelClass
abstract class SpaceBarItem : VectorEpoxyModel<SpaceBarItem.Holder>(R.layout.space_bar_item) {
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute var matrixItem: MatrixItem? = null
@EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var unreadCount: Int = 0
@EpoxyAttribute var markedUnread: Boolean = false
@EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var selected: Boolean = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var itemLongClickListener: View.OnLongClickListener? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var itemClickListener: ClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.rootView.onClick(itemClickListener)
holder.rootView.setOnLongClickListener {
it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
itemLongClickListener?.onLongClick(it) ?: false
}
matrixItem.let {
if (it == null) {
holder.avatarImageView.setImageResource(R.drawable.ic_space_home)
holder.avatarImageView.imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(holder.avatarImageView.context, R.attr.vctr_content_primary))
holder.avatarImageView.contentDescription = holder.rootView.context.getString(R.string.group_details_home)
} else {
holder.avatarImageView.imageTintList = null
avatarRenderer.render(it, holder.avatarImageView)
holder.avatarImageView.contentDescription = it.getBestName()
}
}
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State.Count(unreadNotificationCount, showHighlighted, unreadCount, markedUnread))
holder.rootView.isChecked = selected
}
override fun unbind(holder: Holder) {
holder.rootView.setOnClickListener(null)
holder.rootView.setOnLongClickListener(null)
avatarRenderer.clear(holder.avatarImageView)
super.unbind(holder)
}
class Holder : VectorEpoxyHolder() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.spaceUnreadCounterBadgeView)
val avatarImageView by bind<ImageView>(R.id.spaceImageView)
val rootView by bind<CheckableConstraintLayout>(R.id.spaceRoot)
}
}

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true">
<layer-list>
<item>
<shape>
<solid android:color="@android:color/transparent" />
</shape>
</item>
<item android:gravity="center_horizontal|bottom" >
<shape>
<size android:width="40dp" android:height="4dp" />
<solid android:color="?colorSecondary" />
<corners android:topLeftRadius="8dp" android:topRightRadius="8dp" />
</shape>
</item>
</layer-list>
</item>
<item>
<shape>
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>

View File

@ -150,7 +150,7 @@
android:id="@+id/roomListContainerStateView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView"
app:layout_constraintBottom_toTopOf="@+id/spaceBarRecyclerView"
app:layout_constraintTop_toBottomOf="@+id/homeKeysBackupBanner">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/roomListContainerPager"
@ -158,6 +158,29 @@
android:layout_height="match_parent" />
</im.vector.app.core.platform.StateView>
<!-- Own view for spacebar bg in case of wrap_content of spaceBarRecyclerView is smaller than screen estate -->
<View
android:id="@+id/spaceBarBackground"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@drawable/bg_bottom_navigation"
android:elevation="4dp"
app:layout_constraintBottom_toBottomOf="@id/spaceBarRecyclerView"
app:layout_constraintTop_toTopOf="@id/spaceBarRecyclerView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- wrap_content -> center when fewer spaces than screen estate -->
<!-- needs higher elevation than spaceBarBackground to be rendered above: https://stackoverflow.com/a/27554343 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/spaceBarRecyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:elevation="80dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigationView"
android:layout_width="0dp"

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.app.core.platform.CheckableConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/spaceRoot"
android:layout_width="60dp"
android:layout_height="48dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:background="@drawable/bg_bottom_space_item"
android:padding="8dp"
android:clipChildren="false"
android:clipToPadding="false"
tools:viewBindingIgnore="true">
<ImageView
android:id="@+id/spaceImageView"
android:layout_width="32dp"
android:layout_height="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:ignore="ContentDescription"
tools:src="@sample/room_round_avatars" />
<im.vector.app.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/spaceUnreadCounterBadgeView"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:minWidth="18dp"
android:minHeight="18dp"
android:textColor="?colorOnError"
android:visibility="gone"
app:layout_constraintCircle="@id/spaceImageView"
app:layout_constraintCircleAngle="45"
app:layout_constraintCircleRadius="21dp"
tools:background="@drawable/bg_unread_highlight"
tools:text="24"
tools:visibility="visible" />
<!--
<TextView
android:id="@+id/spaceTitle"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="8dp"
android:ellipsize="end"
android:importantForAccessibility="no"
android:lines="1"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/spaceImageView"
tools:text="Coffee" />
-->
</im.vector.app.core.platform.CheckableConstraintLayout>