From 5af6bf3762e460e7db1bb4860e59ba14d1091f01 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 24 Jul 2019 18:28:03 +0200 Subject: [PATCH] Direct room: finally handle selection with chips (not as Nad design) --- vector/build.gradle | 2 +- .../core/platform/MaxHeightScrollView.kt | 72 ++++++++++++ .../createdirect/CreateDirectRoomActivity.kt | 14 ++- .../CreateDirectRoomDirectoryUsersFragment.kt | 2 + .../createdirect/CreateDirectRoomFragment.kt | 104 +++++++++--------- .../createdirect/CreateDirectRoomUserItem.kt | 7 +- .../createdirect/CreateDirectRoomViewModel.kt | 32 +++--- .../layout/fragment_create_direct_room.xml | 48 ++++++-- .../layout/item_create_direct_room_user.xml | 1 + .../values/attrs_max_height_scroll_view.xml | 6 + 10 files changed, 201 insertions(+), 87 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt create mode 100644 vector/src/main/res/values/attrs_max_height_scroll_view.xml diff --git a/vector/build.gradle b/vector/build.gradle index db9ad6d63a..a864574792 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -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" diff --git a/vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt b/vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt new file mode 100644 index 0000000000..92796bbda8 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomActivity.kt index 82b8b812b4..c31cb8c0c6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomActivity.kt @@ -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) { + 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) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt index f19abaa3fc..ad3a8f3354 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt @@ -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 { diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomFragment.kt index 6f0c1727c7..57c1783bdc 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomFragment.kt @@ -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) { + 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)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt index 96a5ce0be7..c6d7f85b5a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt @@ -40,6 +40,7 @@ abstract class CreateDirectRoomUserItem : VectorEpoxyModel + 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 - 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) } } } diff --git a/vector/src/main/res/layout/fragment_create_direct_room.xml b/vector/src/main/res/layout/fragment_create_direct_room.xml index 987f902bc1..11a74d6b07 100644 --- a/vector/src/main/res/layout/fragment_create_direct_room.xml +++ b/vector/src/main/res/layout/fragment_create_direct_room.xml @@ -58,28 +58,52 @@ - + app:layout_constraintTop_toBottomOf="@+id/createDirectRoomToolbar" + app:maxHeight="80dp"> - + app:lineSpacing="4dp" /> - + + + + + + app:layout_constraintTop_toBottomOf="@id/createDirectRoomFilterDivider" /> diff --git a/vector/src/main/res/values/attrs_max_height_scroll_view.xml b/vector/src/main/res/values/attrs_max_height_scroll_view.xml new file mode 100644 index 0000000000..1b13506674 --- /dev/null +++ b/vector/src/main/res/values/attrs_max_height_scroll_view.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file