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
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 "ru.noties.markwon:core:$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 androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProviders
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.viewModel
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.error.ErrorFormatter
@ -72,14 +72,16 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
if (isFirstCreation()) {
addFragment(CreateDirectRoomFragment(), R.id.container)
}
viewModel.subscribe(this) { renderState(it) }
viewModel.selectSubscribe(this, CreateDirectRoomViewState::createAndInviteState) {
renderCreateAndInviteState(it)
}
}

private fun renderState(state: CreateDirectRoomViewState) {
when (state.createAndInviteState) {
private fun renderCreateAndInviteState(state: Async<String>) {
when (state) {
is Loading -> renderCreationLoading()
is Success -> renderCreationSuccess(state.createAndInviteState())
is Fail -> renderCreationFailure(state.createAndInviteState.error)
is Success -> renderCreationSuccess(state())
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.core.di.ScreenComponent
import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.setupAsSearch
import im.vector.riotx.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.*
import javax.inject.Inject
@ -60,6 +61,7 @@ class CreateDirectRoomDirectoryUsersFragment : VectorBaseFragment(), CreateDirec
}

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

View File

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

import android.os.Bundle
import android.text.Spannable
import android.view.Menu
import android.view.MenuItem
import android.widget.ScrollView
import androidx.core.view.size
import androidx.lifecycle.ViewModelProviders
import com.airbnb.mvrx.activityViewModel
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 im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.hideKeyboard
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.utils.DimensionUtils
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.html.PillImageSpan
import kotlinx.android.synthetic.main.fragment_create_direct_room.*
import javax.inject.Inject

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

updateChipsView(it)
}
viewModel.selectSubscribe(this, CreateDirectRoomViewState::selectedUsers) {
renderSelectedUsers(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 {
return when (item.itemId) {
R.id.action_create_direct_room -> {
@ -100,14 +113,7 @@ class CreateDirectRoomFragment : VectorBaseFragment(), CreateDirectRoomControlle
createDirectRoomFilter
.textChanges()
.subscribe { text ->
val userMatches = MatrixPatterns.PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER.findAll(text)
val lastUserMatch = userMatches.lastOrNull()
val filterValue = if (lastUserMatch == null) {
text
} else {
text.substring(startIndex = lastUserMatch.range.endInclusive + 1)
}.trim()

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

createDirectRoomFilter
.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.setupAsSearch()
createDirectRoomFilter.requestFocus()
}

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

private fun updateFilterViewWith(data: SelectUserAction) = withState(viewModel) { state ->
if (state.selectedUsers.isEmpty()) {
createDirectRoomFilter.text = null
private fun updateChipsView(data: SelectUserAction) {
if (data.isAdded) {
addChipToGroup(data.user, chipGroup)
} else {
val editable = createDirectRoomFilter.editableText
val user = data.user
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, "")
}
if (chipGroup.size > data.index) {
chipGroup.removeViewAt(data.index)
}
}
}

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) {
view?.hideKeyboard()
viewModel.handle(CreateDirectRoomActions.SelectUser(user))

View File

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


override fun bind(holder: Holder) {
holder.view.setOnClickListener(clickListener)
// 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.userIdView.text = userId
}
if (selected) {
renderSelection(holder, selected)
}

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

View File

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

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

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

private fun handleRemoveSelectedUser(action: CreateDirectRoomActions.RemoveSelectedUser) = withState {
val selectedUsers = it.selectedUsers.minus(action.user)
private fun handleRemoveSelectedUser(action: CreateDirectRoomActions.RemoveSelectedUser) = withState { state ->
val index = state.selectedUsers.indexOfFirst { it.userId == action.user.userId }
val selectedUsers = state.selectedUsers.minus(action.user)
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
knownUsersFilter.accept(Option.empty())
directoryUsersSearch.accept("")

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

private fun observeDirectoryUsers() {
@ -153,7 +157,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
} else {
users.filter {
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.cardview.widget.CardView
android:id="@+id/createDirectRoomFilterContainer"
<im.vector.riotx.core.platform.MaxHeightScrollView
android:id="@+id/chipGroupContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:cardElevation="4dp"
app:cardUseCompatPadding="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/createDirectRoomToolbar">
app:layout_constraintTop_toBottomOf="@+id/createDirectRoomToolbar"
app:maxHeight="80dp">

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

</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
android:id="@+id/addByMatrixId"
@ -97,7 +121,7 @@
app:iconPadding="13dp"
app:iconTint="@color/riotx_accent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/createDirectRoomFilterContainer" />
app:layout_constraintTop_toBottomOf="@id/createDirectRoomFilterDivider" />

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

View File

@ -30,6 +30,7 @@
android:id="@+id/createDirectRoomUserAvatarChecked"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerInside"
android:src="@drawable/ic_material_done"
android:tint="@android:color/white"
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>