Direct room: finally handle selection with chips (not as Nad design)

This commit is contained in:
ganfra 2019-07-24 18:28:03 +02:00 committed by ganfra
parent 507bc2f622
commit 5af6bf3762
10 changed files with 201 additions and 87 deletions

View File

@ -206,7 +206,7 @@ dependencies {


// UI // UI
implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
implementation 'com.google.android.material:material:1.1.0-alpha07' implementation 'com.google.android.material:material:1.1.0-alpha08'
implementation 'me.gujun.android:span:1.7' implementation 'me.gujun.android:span:1.7'
implementation "ru.noties.markwon:core:$markwon_version" implementation "ru.noties.markwon:core:$markwon_version"
implementation "ru.noties.markwon:html:$markwon_version" implementation "ru.noties.markwon:html:$markwon_version"

View File

@ -0,0 +1,72 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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.riotx.core.platform

import android.annotation.TargetApi
import android.content.Context
import android.content.res.TypedArray
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.widget.ScrollView

import im.vector.riotx.R

private const val DEFAULT_MAX_HEIGHT = 200

class MaxHeightScrollView : ScrollView {

var maxHeight: Int = 0
set(value) {
field = value
requestLayout()
}

constructor(context: Context) : super(context) {}

constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
if (!isInEditMode) {
init(context, attrs)
}
}

constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
if (!isInEditMode) {
init(context, attrs)
}
}

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
if (!isInEditMode) {
init(context, attrs)
}
}

private fun init(context: Context, attrs: AttributeSet?) {
if (attrs != null) {
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightScrollView)
maxHeight = styledAttrs.getDimensionPixelSize(R.styleable.MaxHeightScrollView_maxHeight, DEFAULT_MAX_HEIGHT)
styledAttrs.recycle()
}
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
super.onMeasure(widthMeasureSpec, newHeightMeasureSpec)
}
}

View File

@ -24,11 +24,11 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.viewModel import com.airbnb.mvrx.viewModel
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.error.ErrorFormatter
@ -72,14 +72,16 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
if (isFirstCreation()) { if (isFirstCreation()) {
addFragment(CreateDirectRoomFragment(), R.id.container) addFragment(CreateDirectRoomFragment(), R.id.container)
} }
viewModel.subscribe(this) { renderState(it) } viewModel.selectSubscribe(this, CreateDirectRoomViewState::createAndInviteState) {
renderCreateAndInviteState(it)
}
} }


private fun renderState(state: CreateDirectRoomViewState) { private fun renderCreateAndInviteState(state: Async<String>) {
when (state.createAndInviteState) { when (state) {
is Loading -> renderCreationLoading() is Loading -> renderCreationLoading()
is Success -> renderCreationSuccess(state.createAndInviteState()) is Success -> renderCreationSuccess(state())
is Fail -> renderCreationFailure(state.createAndInviteState.error) is Fail -> renderCreationFailure(state.error)
} }
} }



View File

@ -26,6 +26,7 @@ import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.setupAsSearch
import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.* import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.*
import javax.inject.Inject import javax.inject.Inject
@ -60,6 +61,7 @@ class CreateDirectRoomDirectoryUsersFragment : VectorBaseFragment(), CreateDirec
} }


private fun setupSearchByMatrixIdView() { private fun setupSearchByMatrixIdView() {
createDirectRoomSearchById.setupAsSearch()
createDirectRoomSearchById createDirectRoomSearchById
.textChanges() .textChanges()
.subscribe { .subscribe {

View File

@ -19,23 +19,25 @@
package im.vector.riotx.features.home.createdirect package im.vector.riotx.features.home.createdirect


import android.os.Bundle import android.os.Bundle
import android.text.Spannable import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.ScrollView
import androidx.core.view.size
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import com.jakewharton.rxbinding3.widget.beforeTextChangeEvents import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.jakewharton.rxbinding3.widget.textChanges import com.jakewharton.rxbinding3.widget.textChanges
import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.extensions.setupAsSearch
import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.DimensionUtils
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.html.PillImageSpan
import kotlinx.android.synthetic.main.fragment_create_direct_room.* import kotlinx.android.synthetic.main.fragment_create_direct_room.*
import javax.inject.Inject import javax.inject.Inject


@ -64,12 +66,23 @@ class CreateDirectRoomFragment : VectorBaseFragment(), CreateDirectRoomControlle
setupAddByMatrixIdView() setupAddByMatrixIdView()
setupCloseView() setupCloseView()
viewModel.selectUserEvent.observeEvent(this) { viewModel.selectUserEvent.observeEvent(this) {
updateFilterViewWith(it) updateChipsView(it)

}
viewModel.selectSubscribe(this, CreateDirectRoomViewState::selectedUsers) {
renderSelectedUsers(it)
} }
viewModel.subscribe(this) { renderState(it) } viewModel.subscribe(this) { renderState(it) }
} }


override fun onPrepareOptionsMenu(menu: Menu) {
withState(viewModel) {
val createMenuItem = menu.findItem(R.id.action_create_direct_room)
val showMenuItem = it.selectedUsers.isNotEmpty()
createMenuItem.setVisible(showMenuItem)
}
super.onPrepareOptionsMenu(menu)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_create_direct_room -> { R.id.action_create_direct_room -> {
@ -100,14 +113,7 @@ class CreateDirectRoomFragment : VectorBaseFragment(), CreateDirectRoomControlle
createDirectRoomFilter createDirectRoomFilter
.textChanges() .textChanges()
.subscribe { text -> .subscribe { text ->
val userMatches = MatrixPatterns.PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER.findAll(text) val filterValue = text.trim()
val lastUserMatch = userMatches.lastOrNull()
val filterValue = if (lastUserMatch == null) {
text
} else {
text.substring(startIndex = lastUserMatch.range.endInclusive + 1)
}.trim()

val action = if (filterValue.isBlank()) { val action = if (filterValue.isBlank()) {
CreateDirectRoomActions.ClearFilterKnownUsers CreateDirectRoomActions.ClearFilterKnownUsers
} else { } else {
@ -117,23 +123,7 @@ class CreateDirectRoomFragment : VectorBaseFragment(), CreateDirectRoomControlle
} }
.disposeOnDestroy() .disposeOnDestroy()


createDirectRoomFilter createDirectRoomFilter.setupAsSearch()
.beforeTextChangeEvents()
.subscribe { event ->
if (event.after == 0) {
val sub = event.text.substring(0, event.start)
val startIndexOfUser = sub.lastIndexOf(" ") + 1
val user = sub.substring(startIndexOfUser)
val selectedUser = withState(viewModel) { state ->
state.selectedUsers.find { it.userId == user }
}
if (selectedUser != null) {
viewModel.handle(CreateDirectRoomActions.RemoveSelectedUser(selectedUser))
}
}
}
.disposeOnDestroy()

createDirectRoomFilter.requestFocus() createDirectRoomFilter.requestFocus()
} }


@ -147,32 +137,40 @@ class CreateDirectRoomFragment : VectorBaseFragment(), CreateDirectRoomControlle
directRoomController.setData(state) directRoomController.setData(state)
} }


private fun updateFilterViewWith(data: SelectUserAction) = withState(viewModel) { state -> private fun updateChipsView(data: SelectUserAction) {
if (state.selectedUsers.isEmpty()) { if (data.isAdded) {
createDirectRoomFilter.text = null addChipToGroup(data.user, chipGroup)
} else { } else {
val editable = createDirectRoomFilter.editableText if (chipGroup.size > data.index) {
val user = data.user chipGroup.removeViewAt(data.index)
if (data.isAdded) {
val startIndex = editable.lastIndexOf(" ") + 1
val endIndex = editable.length
editable.replace(startIndex, endIndex, "${user.userId} ")
val span = PillImageSpan(GlideApp.with(this), avatarRenderer, requireContext(), user.userId, user)
span.bind(createDirectRoomFilter)
editable.setSpan(span, startIndex, startIndex + user.userId.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} else {
val startIndex = editable.indexOf(user.userId)
if (startIndex != -1) {
var endIndex = editable.indexOf(" ", startIndex) + 1
if (endIndex == 0) {
endIndex = editable.length
}
editable.replace(startIndex, endIndex, "")
}
} }
} }
} }


private fun renderSelectedUsers(selectedUsers: Set<User>) {
vectorBaseActivity.invalidateOptionsMenu()
if (selectedUsers.isNotEmpty() && chipGroup.size == 0) {
selectedUsers.forEach { addChipToGroup(it, chipGroup) }
}
}

private fun addChipToGroup(user: User, chipGroup: ChipGroup) {
val chip = Chip(requireContext())
chip.setChipBackgroundColorResource(android.R.color.transparent)
chip.chipStrokeWidth = DimensionUtils.dpToPx(1, requireContext()).toFloat()
chip.text = if (user.displayName.isNullOrBlank()) user.userId else user.displayName
chip.isClickable = true
chip.isCheckable = false
chip.isCloseIconVisible = true
chipGroup.addView(chip)
chip.setOnCloseIconClickListener {
viewModel.handle(CreateDirectRoomActions.RemoveSelectedUser(user))
}
chipGroupContainer.post {
chipGroupContainer.fullScroll(ScrollView.FOCUS_DOWN)
}
}

override fun onItemClick(user: User) { override fun onItemClick(user: User) {
view?.hideKeyboard() view?.hideKeyboard()
viewModel.handle(CreateDirectRoomActions.SelectUser(user)) viewModel.handle(CreateDirectRoomActions.SelectUser(user))

View File

@ -40,6 +40,7 @@ abstract class CreateDirectRoomUserItem : VectorEpoxyModel<CreateDirectRoomUserI
@EpoxyAttribute var clickListener: View.OnClickListener? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null
@EpoxyAttribute var selected: Boolean = false @EpoxyAttribute var selected: Boolean = false



override fun bind(holder: Holder) { override fun bind(holder: Holder) {
holder.view.setOnClickListener(clickListener) holder.view.setOnClickListener(clickListener)
// If name is empty, use userId as name and force it being centered // If name is empty, use userId as name and force it being centered
@ -51,7 +52,11 @@ abstract class CreateDirectRoomUserItem : VectorEpoxyModel<CreateDirectRoomUserI
holder.nameView.text = name holder.nameView.text = name
holder.userIdView.text = userId holder.userIdView.text = userId
} }
if (selected) { renderSelection(holder, selected)
}

private fun renderSelection(holder: Holder, isSelected: Boolean) {
if (isSelected) {
holder.avatarCheckedImageView.visibility = View.VISIBLE holder.avatarCheckedImageView.visibility = View.VISIBLE
val backgroundColor = ContextCompat.getColor(holder.view.context, R.color.riotx_accent) val backgroundColor = ContextCompat.getColor(holder.view.context, R.color.riotx_accent)
val backgroundDrawable = TextDrawable.builder().buildRound("", backgroundColor) val backgroundDrawable = TextDrawable.builder().buildRound("", backgroundColor)

View File

@ -41,7 +41,8 @@ private typealias DirectoryUsersSearch = String


data class SelectUserAction( data class SelectUserAction(
val user: User, val user: User,
val isAdded: Boolean val isAdded: Boolean,
val index: Int
) )


class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
@ -102,28 +103,31 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
.disposeOnClear() .disposeOnClear()
} }


private fun handleRemoveSelectedUser(action: CreateDirectRoomActions.RemoveSelectedUser) = withState { private fun handleRemoveSelectedUser(action: CreateDirectRoomActions.RemoveSelectedUser) = withState { state ->
val selectedUsers = it.selectedUsers.minus(action.user) val index = state.selectedUsers.indexOfFirst { it.userId == action.user.userId }
val selectedUsers = state.selectedUsers.minus(action.user)
setState { copy(selectedUsers = selectedUsers) } setState { copy(selectedUsers = selectedUsers) }
_selectUserEvent.postLiveEvent(SelectUserAction(action.user, false)) _selectUserEvent.postLiveEvent(SelectUserAction(action.user, false, index))
} }


private fun handleSelectUser(action: CreateDirectRoomActions.SelectUser) = withState { private fun handleSelectUser(action: CreateDirectRoomActions.SelectUser) = withState { state ->
//Reset the filter asap //Reset the filter asap
knownUsersFilter.accept(Option.empty())
directoryUsersSearch.accept("") directoryUsersSearch.accept("")

val isAddOperation: Boolean val isAddOperation: Boolean
val selectedUsers: Set<User> val selectedUsers: Set<User>
if (it.selectedUsers.contains(action.user)) { val indexOfUser = state.selectedUsers.indexOfFirst { it.userId == action.user.userId }
selectedUsers = it.selectedUsers.minus(action.user) val changeIndex: Int
isAddOperation = false if (indexOfUser == -1) {
} else { changeIndex = state.selectedUsers.size
selectedUsers = it.selectedUsers.plus(action.user) selectedUsers = state.selectedUsers.plus(action.user)
isAddOperation = true isAddOperation = true
} else {
changeIndex = indexOfUser
selectedUsers = state.selectedUsers.minus(action.user)
isAddOperation = false
} }
setState { copy(selectedUsers = selectedUsers) } setState { copy(selectedUsers = selectedUsers) }
_selectUserEvent.postLiveEvent(SelectUserAction(action.user, isAddOperation)) _selectUserEvent.postLiveEvent(SelectUserAction(action.user, isAddOperation, changeIndex))
} }


private fun observeDirectoryUsers() { private fun observeDirectoryUsers() {
@ -153,7 +157,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
} else { } else {
users.filter { users.filter {
it.displayName?.contains(filterValue, ignoreCase = true) ?: false it.displayName?.contains(filterValue, ignoreCase = true) ?: false
|| it.userId.contains(filterValue, ignoreCase = true) || it.userId.contains(filterValue, ignoreCase = true)
} }
} }
} }

View File

@ -58,28 +58,52 @@


</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>


<androidx.cardview.widget.CardView <im.vector.riotx.core.platform.MaxHeightScrollView
android:id="@+id/createDirectRoomFilterContainer" android:id="@+id/chipGroupContainer"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin" android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin" android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:cardElevation="4dp"
app:cardUseCompatPadding="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/createDirectRoomToolbar"> app:layout_constraintTop_toBottomOf="@+id/createDirectRoomToolbar"
app:maxHeight="80dp">


<EditText <com.google.android.material.chip.ChipGroup
android:id="@+id/createDirectRoomFilter" android:id="@+id/chipGroup"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:maxHeight="80dp" app:lineSpacing="4dp" />
android:importantForAutofill="no"
android:hint="@string/room_directory_search_hint"/>


</androidx.cardview.widget.CardView> </im.vector.riotx.core.platform.MaxHeightScrollView>

<EditText
android:id="@+id/createDirectRoomFilter"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:background="@null"
android:hint="@string/room_directory_search_hint"
android:importantForAutofill="no"
android:maxHeight="80dp"
android:padding="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chipGroupContainer" />

<View
android:id="@+id/createDirectRoomFilterDivider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?attr/vctr_list_divider_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/createDirectRoomFilter" />


<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/addByMatrixId" android:id="@+id/addByMatrixId"
@ -97,7 +121,7 @@
app:iconPadding="13dp" app:iconPadding="13dp"
app:iconTint="@color/riotx_accent" app:iconTint="@color/riotx_accent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/createDirectRoomFilterContainer" /> app:layout_constraintTop_toBottomOf="@id/createDirectRoomFilterDivider" />


<com.airbnb.epoxy.EpoxyRecyclerView <com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"

View File

@ -30,6 +30,7 @@
android:id="@+id/createDirectRoomUserAvatarChecked" android:id="@+id/createDirectRoomUserAvatarChecked"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:scaleType="centerInside"
android:src="@drawable/ic_material_done" android:src="@drawable/ic_material_done"
android:tint="@android:color/white" android:tint="@android:color/white"
android:visibility="visible" /> android:visibility="visible" />

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MaxHeightScrollView">
<attr name="maxHeight" format="dimension" />
</declare-styleable>
</resources>