From 7417241cd58d8738566304c91b11e918f6a472cf Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 18 Nov 2022 08:57:37 +0100 Subject: [PATCH] New RTE full screen implementation with BottomSheet (#7578) * RTE full screen editor using custom BottomSheet * Fix formatting menu item dimensions * Fix bug with insets when opening attachment menu * Clear the EditText for plain text mode when a message is sent * Set `MessageComposerMode.Special` as a sealed class * Fix insets issue on landscape * Fix small UI issues with rounded corners * Use simplified icons for full screen and minimise --- changelog.d/7577.feature | 1 + .../src/main/res/values/strings.xml | 3 + .../ui-styles/src/main/res/values/dimens.xml | 1 + .../src/main/res/values/styles_edit_text.xml | 11 +- .../utils/ExpandingBottomSheetBehavior.kt | 791 ++++++++++++++++++ .../JumpToBottomViewVisibilityManager.kt | 10 +- .../home/room/detail/RoomDetailActivity.kt | 7 + .../home/room/detail/TimelineFragment.kt | 46 +- .../detail/composer/MessageComposerAction.kt | 1 - .../composer/MessageComposerFragment.kt | 170 ++-- .../detail/composer/MessageComposerMode.kt | 28 + .../detail/composer/MessageComposerView.kt | 21 +- .../composer/PlainTextComposerLayout.kt | 198 +++-- .../detail/composer/RichTextComposerLayout.kt | 287 +++++-- .../composer/voice/VoiceMessageViews.kt | 6 +- .../bg_composer_rich_bottom_sheet.xml | 5 + .../bg_composer_rich_edit_text_expanded.xml | 13 - ...bg_composer_rich_edit_text_single_line.xml | 13 - .../main/res/drawable/bottomsheet_handle.xml | 6 + .../res/drawable/ic_composer_collapse.xml | 9 + .../res/drawable/ic_composer_full_screen.xml | 14 +- .../drawable/ic_composer_rich_mic_pressed.xml | 17 + .../ic_composer_rich_text_editor_close.xml | 9 + .../ic_composer_rich_text_editor_edit.xml | 12 + .../drawable/ic_composer_rich_text_save.xml | 16 + .../res/drawable/ic_rich_composer_add.xml | 15 + .../res/drawable/ic_rich_composer_send.xml | 12 + .../res/drawable/ic_voice_mic_recording.xml | 10 - .../src/main/res/layout/composer_layout.xml | 322 ++++--- ...composer_layout_constraint_set_compact.xml | 197 ----- ...omposer_layout_constraint_set_expanded.xml | 197 ----- .../res/layout/composer_rich_text_layout.xml | 346 ++++---- ...ich_text_layout_constraint_set_compact.xml | 233 ------ ...ch_text_layout_constraint_set_expanded.xml | 230 ----- ..._text_layout_constraint_set_fullscreen.xml | 234 ------ .../src/main/res/layout/fragment_composer.xml | 7 +- .../src/main/res/layout/fragment_timeline.xml | 364 ++++---- .../layout/fragment_timeline_fullscreen.xml | 258 ------ .../res/layout/view_rich_text_menu_button.xml | 4 +- .../layout/view_voice_message_recorder.xml | 18 +- 40 files changed, 1904 insertions(+), 2238 deletions(-) create mode 100644 changelog.d/7577.feature create mode 100644 vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt create mode 100644 vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml delete mode 100644 vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml delete mode 100644 vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml create mode 100644 vector/src/main/res/drawable/bottomsheet_handle.xml create mode 100644 vector/src/main/res/drawable/ic_composer_collapse.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_text_save.xml create mode 100644 vector/src/main/res/drawable/ic_rich_composer_add.xml create mode 100644 vector/src/main/res/drawable/ic_rich_composer_send.xml delete mode 100644 vector/src/main/res/drawable/ic_voice_mic_recording.xml delete mode 100644 vector/src/main/res/layout/composer_layout_constraint_set_compact.xml delete mode 100644 vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml delete mode 100644 vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml delete mode 100644 vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml delete mode 100644 vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml delete mode 100644 vector/src/main/res/layout/fragment_timeline_fullscreen.xml diff --git a/changelog.d/7577.feature b/changelog.d/7577.feature new file mode 100644 index 0000000000..e21ccb13c0 --- /dev/null +++ b/changelog.d/7577.feature @@ -0,0 +1 @@ +New implementation of the full screen mode for the Rich Text Editor. diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index e503cb3fe7..ab98f7e141 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -1642,7 +1642,10 @@ It looks like you’re trying to connect to another homeserver. Do you want to sign out? Edit + Editing Reply + Replying to %s + Quoting Reply in thread View In Room diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 22c2a3e62c..4c911c9e97 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -49,6 +49,7 @@ 1dp 28dp 14dp + 44dp 28dp 6dp diff --git a/library/ui-styles/src/main/res/values/styles_edit_text.xml b/library/ui-styles/src/main/res/values/styles_edit_text.xml index b640fc49d9..94f4d86160 100644 --- a/library/ui-styles/src/main/res/values/styles_edit_text.xml +++ b/library/ui-styles/src/main/res/values/styles_edit_text.xml @@ -4,7 +4,7 @@ diff --git a/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt b/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt new file mode 100644 index 0000000000..0474cdea7e --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt @@ -0,0 +1,791 @@ +package im.vector.app.core.utils + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.View +import android.view.View.MeasureSpec +import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.customview.widget.ViewDragHelper +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import timber.log.Timber +import java.lang.ref.WeakReference +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * BottomSheetBehavior that dynamically resizes its contents as it grows or shrinks. + * Most of the nested scrolling and touch events code is the same as in [BottomSheetBehavior], but we couldn't just extend it. + */ +class ExpandingBottomSheetBehavior : CoordinatorLayout.Behavior { + + companion object { + /** Gets a [ExpandingBottomSheetBehavior] from the passed [view] if it exists. */ + @Suppress("UNCHECKED_CAST") + fun from(view: V): ExpandingBottomSheetBehavior? { + val params = view.layoutParams as? CoordinatorLayout.LayoutParams ?: return null + return params.behavior as? ExpandingBottomSheetBehavior + } + } + + /** [Callback] to notify changes in dragging state and position. */ + interface Callback { + /** Called when the dragging state of the BottomSheet changes. */ + fun onStateChanged(state: State) {} + + /** Called when the position of the BottomSheet changes while dragging. */ + fun onSlidePositionChanged(view: View, yPosition: Float) {} + } + + /** Represents the 4 possible states of the BottomSheet. */ + enum class State(val value: Int) { + /** BottomSheet is at min height, collapsed at the bottom. */ + Collapsed(0), + + /** BottomSheet is being dragged by the user. */ + Dragging(1), + + /** BottomSheet has been released after being dragged by the user and is animating to its destination. */ + Settling(2), + + /** BottomSheet is at its max height. */ + Expanded(3); + + /** Returns whether the BottomSheet is being dragged or is settling after being dragged. */ + fun isDraggingOrSettling(): Boolean = this == Dragging || this == Settling + } + + /** Set to true to enable debug logging of sizes and offsets. Defaults to `false`. */ + var enableDebugLogs = false + + /** Current BottomSheet state. Default to [State.Collapsed]. */ + var state: State = State.Collapsed + private set + + /** Whether the BottomSheet can be dragged by the user or not. Defaults to `true`. */ + var isDraggable = true + + /** [Callback] to notify changes in dragging state and position. */ + var callback: Callback? = null + set(value) { + field = value + // Send initial state + value?.onStateChanged(state) + } + + /** Additional top offset in `dps` to add to the BottomSheet so it doesn't fill the whole screen. Defaults to `0`. */ + var topOffset = 0 + set(value) { + field = value + expandedOffset = -1 + } + + /** Whether the BottomSheet should be expanded up to the bottom of any [AppBarLayout] found in the parent [CoordinatorLayout]. Defaults to `false`. */ + var avoidAppBarLayout = false + set(value) { + field = value + expandedOffset = -1 + } + + /** + * Whether to add the [scrimView], a 'shadow layer' that will be displayed while dragging/expanded so it obscures the content below the BottomSheet. + * Defaults to `false`. + */ + var useScrimView = false + + /** Color to use for the [scrimView] shadow layer. */ + var scrimViewColor = 0x60000000 + + /** [View.TRANSLATION_Z] in `dps` to apply to the [scrimView]. Defaults to `0dp`. */ + var scrimViewTranslationZ = 0 + + /** Whether the content view should be layout to the top of the BottomSheet when it's collapsed. Defaults to true. */ + var applyInsetsToContentViewWhenCollapsed = true + + /** Lambda used to calculate a min collapsed when the view using the behavior should have a special 'collapsed' layout. It's null by default. */ + var minCollapsedHeight: (() -> Int)? = null + + // Internal BottomSheet implementation properties + private var ignoreEvents = false + private var touchingScrollingChild = false + + private var lastY: Int = -1 + private var collapsedOffset = -1 + private var expandedOffset = -1 + private var parentWidth = -1 + private var parentHeight = -1 + + private var activePointerId = -1 + + private var lastNestedScrollDy = -1 + private var isNestedScrolled = false + + private var viewRef: WeakReference? = null + private var nestedScrollingChildRef: WeakReference? = null + private var velocityTracker: VelocityTracker? = null + + private var dragHelper: ViewDragHelper? = null + private var scrimView: View? = null + + private val stateSettlingTracker = StateSettlingTracker() + private var prevState: State? = null + + private var insetBottom = 0 + private var insetTop = 0 + private var insetLeft = 0 + private var insetRight = 0 + + private var initialPaddingTop = 0 + private var initialPaddingBottom = 0 + private var initialPaddingLeft = 0 + private var initialPaddingRight = 0 + private val minCollapsedOffset: Int? + get() { + val minHeight = minCollapsedHeight?.invoke() ?: return null + if (minHeight == -1) return null + return parentHeight - minHeight - insetBottom + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor() : super() + + override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { + parentWidth = parent.width + parentHeight = parent.height + + if (viewRef == null) { + viewRef = WeakReference(child) + setWindowInsetsListener(child) + // Prevents clicking on overlapped items below the BottomSheet + child.isClickable = true + } + + parent.updatePadding(left = insetLeft, right = insetRight) + + ensureViewDragHelper(parent) + + // Top coordinate before this layout pass + val savedTop = child.top + + // Calculate default position of the BottomSheet's children + parent.onLayoutChild(child, layoutDirection) + + // This should optimise calculations when they're not needed + if (state == State.Collapsed) { + calculateCollapsedOffset(child) + } + calculateExpandedOffset(parent) + + // Apply top and bottom insets to contentView if needed + val appBar = findAppBarLayout(parent) + val contentView = parent.children.find { it !== appBar && it !== child && it !== scrimView } + if (applyInsetsToContentViewWhenCollapsed && state == State.Collapsed && contentView != null) { + val topOffset = appBar?.measuredHeight ?: 0 + val bottomOffset = parentHeight - collapsedOffset + insetTop + val params = contentView.layoutParams as CoordinatorLayout.LayoutParams + if (params.bottomMargin != bottomOffset || params.topMargin != topOffset) { + params.topMargin = topOffset + params.bottomMargin = bottomOffset + contentView.layoutParams = params + } + } + + // Add scrimView if needed + if (useScrimView && scrimView == null) { + val scrimView = View(parent.context) + scrimView.setBackgroundColor(scrimViewColor) + scrimView.translationZ = scrimViewTranslationZ * child.resources.displayMetrics.scaledDensity + scrimView.isVisible = false + val params = CoordinatorLayout.LayoutParams( + CoordinatorLayout.LayoutParams.MATCH_PARENT, + CoordinatorLayout.LayoutParams.MATCH_PARENT + ) + scrimView.layoutParams = params + val currentIndex = parent.children.indexOf(child) + parent.addView(scrimView, currentIndex) + this.scrimView = scrimView + } else if (!useScrimView && scrimView != null) { + parent.removeView(scrimView) + scrimView = null + } + + // Apply insets and resize child based on the current State + when (state) { + State.Collapsed -> { + scrimView?.alpha = 0f + val newHeight = parentHeight - collapsedOffset + insetTop + val params = child.layoutParams + if (params.height != newHeight) { + params.height = newHeight + child.layoutParams = params + } + // If the offset is < insetTop it will cover the status bar too + val newOffset = max(insetTop, collapsedOffset - insetTop) + ViewCompat.offsetTopAndBottom(child, newOffset) + log("State: Collapsed | Offset: $newOffset | Height: $newHeight") + } + State.Dragging, State.Settling -> { + val newOffset = savedTop - child.top + val percentage = max(0f, 1f - (newOffset.toFloat() / collapsedOffset.toFloat())) + scrimView?.let { + if (percentage == 0f) { + it.isVisible = false + } else { + it.alpha = percentage + it.isVisible = true + } + } + val params = child.layoutParams + params.height = parentHeight - savedTop + child.layoutParams = params + ViewCompat.offsetTopAndBottom(child, newOffset) + val stateStr = if (state == State.Dragging) "Dragging" else "Settling" + log("State: $stateStr | Offset: $newOffset | Percentage: $percentage") + } + State.Expanded -> { + val params = child.layoutParams + val newHeight = parentHeight - expandedOffset + if (params.height != newHeight) { + params.height = newHeight + child.layoutParams = params + } + ViewCompat.offsetTopAndBottom(child, expandedOffset) + log("State: Expanded | Offset: $expandedOffset | Height: $newHeight") + } + } + + // Find a nested scrolling child to take into account for touch events + if (nestedScrollingChildRef == null) { + nestedScrollingChildRef = findScrollingChild(child)?.let { WeakReference(it) } + } + + return true + } + + // region: Touch events + override fun onInterceptTouchEvent( + parent: CoordinatorLayout, + child: V, + ev: MotionEvent + ): Boolean { + // Almost everything inside here is verbatim to BottomSheetBehavior's onTouchEvent + if (viewRef != null && viewRef?.get() !== child) { + return true + } + val action = ev.actionMasked + + if (action == MotionEvent.ACTION_DOWN) { + resetTouchEventTracking() + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(ev) + + when (action) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + touchingScrollingChild = false + activePointerId = MotionEvent.INVALID_POINTER_ID + if (ignoreEvents) { + ignoreEvents = false + return false + } + } + MotionEvent.ACTION_DOWN -> { + val x = ev.x.toInt() + lastY = ev.y.toInt() + + // Only intercept nested scrolling events here if the view not being moved by the + // ViewDragHelper. + val scroll = nestedScrollingChildRef?.get() + if (state != State.Settling) { + if (scroll != null && parent.isPointInChildBounds(scroll, x, lastY)) { + activePointerId = ev.getPointerId(ev.actionIndex) + touchingScrollingChild = true + } + } + ignoreEvents = (activePointerId == MotionEvent.INVALID_POINTER_ID && + !parent.isPointInChildBounds(child, x, lastY)) + } + else -> Unit + } + + if (!ignoreEvents && isDraggable && dragHelper?.shouldInterceptTouchEvent(ev) == true) { + return true + } + + // If using scrim view, a click on it should collapse the bottom sheet + if (useScrimView && state == State.Expanded && action == MotionEvent.ACTION_DOWN) { + val y = ev.y.toInt() + if (y <= expandedOffset) { + setState(State.Collapsed) + return true + } + } + + // We have to handle cases that the ViewDragHelper does not capture the bottom sheet because + // it is not the top most view of its parent. This is not necessary when the touch event is + // happening over the scrolling content as nested scrolling logic handles that case. + val scroll = nestedScrollingChildRef?.get() + return (action == MotionEvent.ACTION_MOVE && + scroll != null && + !ignoreEvents && + state != State.Dragging && + !parent.isPointInChildBounds(scroll, ev.x.toInt(), ev.y.toInt()) && + dragHelper != null && + abs(lastY - ev.y.toInt()) > (dragHelper?.touchSlop ?: 0)) + } + + override fun onTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean { + // Almost everything inside here is verbatim to BottomSheetBehavior's onTouchEvent + val action = ev.actionMasked + if (state == State.Dragging && action == MotionEvent.ACTION_DOWN) { + return true + } + if (shouldHandleDraggingWithHelper()) { + dragHelper?.processTouchEvent(ev) + } + + // Record the velocity + if (action == MotionEvent.ACTION_DOWN) { + resetTouchEventTracking() + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(ev) + + if (shouldHandleDraggingWithHelper() && action == MotionEvent.ACTION_MOVE && !ignoreEvents) { + if (abs(lastY - ev.y.toInt()) > (dragHelper?.touchSlop ?: 0)) { + dragHelper?.captureChildView(child, ev.getPointerId(ev.actionIndex)) + } + } + + return !ignoreEvents + } + + private fun resetTouchEventTracking() { + activePointerId = ViewDragHelper.INVALID_POINTER + velocityTracker?.recycle() + velocityTracker = null + } + // endregion + + override fun onAttachedToLayoutParams(params: CoordinatorLayout.LayoutParams) { + super.onAttachedToLayoutParams(params) + + viewRef = null + dragHelper = null + } + + override fun onDetachedFromLayoutParams() { + super.onDetachedFromLayoutParams() + + viewRef = null + dragHelper = null + } + + // region: Size measuring and utils + private fun calculateCollapsedOffset(child: View) { + val availableSpace = parentHeight - insetTop + child.measure( + MeasureSpec.makeMeasureSpec(parentWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(availableSpace, MeasureSpec.AT_MOST), + ) + collapsedOffset = parentHeight - child.measuredHeight + insetTop + } + + private fun calculateExpandedOffset(parent: CoordinatorLayout): Int { + expandedOffset = if (avoidAppBarLayout) { + findAppBarLayout(parent)?.measuredHeight ?: 0 + } else { + 0 + } + topOffset + insetTop + return expandedOffset + } + + private fun ensureViewDragHelper(parent: CoordinatorLayout) { + if (dragHelper == null) { + dragHelper = ViewDragHelper.create(parent, dragHelperCallback) + } + } + + private fun findAppBarLayout(view: View): AppBarLayout? { + return when (view) { + is AppBarLayout -> view + is ViewGroup -> view.children.firstNotNullOfOrNull { findAppBarLayout(it) } + else -> null + } + } + + private fun shouldHandleDraggingWithHelper(): Boolean { + return dragHelper != null && (isDraggable || state == State.Dragging) + } + + private fun log(contents: String, vararg args: Any) { + if (!enableDebugLogs) return + Timber.d(contents, args) + } + // endregion + + // region: State and delayed state settling + fun setState(state: State) { + if (state == this.state) { + return + } else if (viewRef?.get() == null) { + setInternalState(state) + } else { + viewRef?.get()?.let { child -> + runAfterLayout(child) { startSettling(child, state, false) } + } + } + } + + private fun setInternalState(state: State) { + if (!this.state.isDraggingOrSettling()) { + prevState = this.state + } + this.state = state + + viewRef?.get()?.requestLayout() + + callback?.onStateChanged(state) + } + + private fun startSettling(child: View, state: State, isReleasingView: Boolean) { + val top = getTopOffsetForState(state) + log("Settling to: $top") + val isSettling = dragHelper?.let { + if (isReleasingView) { + it.settleCapturedViewAt(child.left, top) + } else { + it.smoothSlideViewTo(child, child.left, top) + } + } ?: false + setInternalState(if (isSettling) State.Settling else state) + + if (isSettling) { + stateSettlingTracker.continueSettlingToState(state) + } + } + + private fun runAfterLayout(child: V, runnable: Runnable) { + if (isLayouting(child)) { + child.post(runnable) + } else { + runnable.run() + } + } + + private fun isLayouting(child: V): Boolean { + return child.parent != null && child.parent.isLayoutRequested && ViewCompat.isAttachedToWindow(child) + } + + private fun getTopOffsetForState(state: State): Int { + return when (state) { + State.Collapsed -> minCollapsedOffset ?: collapsedOffset + State.Expanded -> expandedOffset + else -> error("Cannot get offset for state $state") + } + } + // endregion + + // region: Nested scroll + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + directTargetChild: View, + target: View, + axes: Int, + type: Int + ): Boolean { + lastNestedScrollDy = 0 + isNestedScrolled = false + return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0 + } + + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int + ) { + if (type == ViewCompat.TYPE_NON_TOUCH) return + val scrollingChild = nestedScrollingChildRef?.get() + if (target != scrollingChild) return + + val currentTop = child.top + val newTop = currentTop - dy + if (dy > 0) { + // Upward scroll + if (newTop < expandedOffset) { + consumed[1] = currentTop - expandedOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setInternalState(State.Expanded) + } else { + if (!isDraggable) return + + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setInternalState(State.Dragging) + } + } else if (dy < 0) { + // Scroll downward + if (!target.canScrollVertically(-1)) { + if (newTop <= collapsedOffset) { + if (!isDraggable) return + + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setInternalState(State.Dragging) + } else { + consumed[1] = currentTop - collapsedOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setInternalState(State.Collapsed) + } + } + } + lastNestedScrollDy = dy + isNestedScrolled = true + } + + override fun onNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + type: Int, + consumed: IntArray + ) { + // Empty to avoid default behaviour + } + + override fun onNestedPreFling( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + velocityX: Float, + velocityY: Float + ): Boolean { + return target == nestedScrollingChildRef?.get() && + (state != State.Expanded || super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY)) + } + + private fun findScrollingChild(view: View): View? { + return when { + !view.isVisible -> null + ViewCompat.isNestedScrollingEnabled(view) -> view + view is ViewGroup -> { + view.children.firstNotNullOfOrNull { findScrollingChild(it) } + } + else -> null + } + } + // endregion + + // region: Insets + private fun setWindowInsetsListener(view: View) { + // Create a snapshot of the view's padding state. + initialPaddingLeft = view.paddingLeft + initialPaddingTop = view.paddingTop + initialPaddingRight = view.paddingRight + initialPaddingBottom = view.paddingBottom + + // This should only be used to set initial insets and other edge cases where the insets can't be applied using an animation. + var applyInsetsFromAnimation = false + + // This will animated inset changes, making them look a lot better. However, it won't update initial insets. + ViewCompat.setWindowInsetsAnimationCallback(view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList): WindowInsetsCompat { + return applyInsets(view, insets) + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + applyInsetsFromAnimation = false + view.requestApplyInsets() + } + }) + + ViewCompat.setOnApplyWindowInsetsListener(view) { _: View, insets: WindowInsetsCompat -> + if (!applyInsetsFromAnimation) { + applyInsetsFromAnimation = true + applyInsets(view, insets) + } else { + insets + } + } + + // Request to apply insets as soon as the view is attached to a window. + if (ViewCompat.isAttachedToWindow(view)) { + ViewCompat.requestApplyInsets(view) + } else { + view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.removeOnAttachStateChangeListener(this) + ViewCompat.requestApplyInsets(v) + } + + override fun onViewDetachedFromWindow(v: View) = Unit + }) + } + } + + private fun applyInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val insetsType = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() + val imeInsets = insets.getInsets(insetsType) + insetTop = imeInsets.top + insetBottom = imeInsets.bottom + insetLeft = imeInsets.left + insetRight = imeInsets.right + + val bottomPadding = initialPaddingBottom + insetBottom + view.setPadding(initialPaddingLeft, initialPaddingTop, initialPaddingRight, bottomPadding) + if (state == State.Collapsed) { + val params = view.layoutParams + params.height = CoordinatorLayout.LayoutParams.WRAP_CONTENT + view.layoutParams = params + calculateCollapsedOffset(view) + } + return WindowInsetsCompat.CONSUMED + } + // endregion + + // Used to add dragging animations along with StateSettlingTracker, and set max and min dragging coordinates. + private val dragHelperCallback = object : ViewDragHelper.Callback() { + + override fun tryCaptureView(child: View, pointerId: Int): Boolean { + if (state == State.Dragging) { + return false + } + + if (touchingScrollingChild) { + return false + } + + if (state == State.Expanded && activePointerId == pointerId) { + val scroll = nestedScrollingChildRef?.get() + if (scroll?.canScrollVertically(-1) == true) { + return false + } + } + + return viewRef?.get() == child + } + + override fun onViewDragStateChanged(state: Int) { + if (state == ViewDragHelper.STATE_DRAGGING && isDraggable) { + setInternalState(State.Dragging) + } + } + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int + ) { + super.onViewPositionChanged(changedView, left, top, dx, dy) + + val params = changedView.layoutParams + params.height = parentHeight - top + insetBottom + insetTop + changedView.layoutParams = params + + val collapsedOffset = minCollapsedOffset ?: collapsedOffset + val percentage = 1f - (top - insetTop).toFloat() / collapsedOffset.toFloat() + + callback?.onSlidePositionChanged(changedView, percentage) + } + + override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { + val actualCollapsedOffset = minCollapsedOffset ?: collapsedOffset + val targetState = if (yvel < 0) { + // Moving up + val currentTop = releasedChild.top + + val yPositionPercentage = currentTop * 100f / actualCollapsedOffset + if (yPositionPercentage >= 0.5f) { + State.Expanded + } else { + State.Collapsed + } + } else if (yvel == 0f || abs(xvel) > abs(yvel)) { + // If the Y velocity is 0 or the swipe was mostly horizontal indicated by the X velocity + // being greater than the Y velocity, settle to the nearest correct height. + + val currentTop = releasedChild.top + if (currentTop < actualCollapsedOffset / 2) { + State.Expanded + } else { + State.Collapsed + } + } else { + // Moving down + val currentTop = releasedChild.top + + val yPositionPercentage = currentTop * 100f / actualCollapsedOffset + if (yPositionPercentage >= 0.5f) { + State.Collapsed + } else { + State.Expanded + } + } + startSettling(releasedChild, targetState, true) + } + + override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { + return child.left + } + + override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { + val collapsed = minCollapsedOffset ?: collapsedOffset + val maxTop = max(top, insetTop) + return min(max(maxTop, expandedOffset), collapsed) + } + + override fun getViewVerticalDragRange(child: View): Int { + return minCollapsedOffset ?: collapsedOffset + } + } + + // Used to set the current State in a delayed way. + private inner class StateSettlingTracker { + private lateinit var targetState: State + private var isContinueSettlingRunnablePosted = false + + private val continueSettlingRunnable: Runnable = Runnable { + isContinueSettlingRunnablePosted = false + if (dragHelper?.continueSettling(true) == true) { + continueSettlingToState(targetState) + } else { + setInternalState(targetState) + } + } + + fun continueSettlingToState(state: State) { + val view = viewRef?.get() ?: return + + this.targetState = state + if (!isContinueSettlingRunnablePosted) { + ViewCompat.postOnAnimation(view, continueSettlingRunnable) + isContinueSettlingRunnablePosted = true + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt index 1368b71ec6..0f7dc251ae 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt @@ -34,8 +34,6 @@ class JumpToBottomViewVisibilityManager( private val layoutManager: LinearLayoutManager ) { - private var canShowButtonOnScroll = true - init { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -45,7 +43,7 @@ class JumpToBottomViewVisibilityManager( if (scrollingToPast) { jumpToBottomView.hide() - } else if (canShowButtonOnScroll) { + } else { maybeShowJumpToBottomViewVisibility() } } @@ -68,13 +66,7 @@ class JumpToBottomViewVisibilityManager( } } - fun hideAndPreventVisibilityChangesWithScrolling() { - jumpToBottomView.hide() - canShowButtonOnScroll = false - } - private fun maybeShowJumpToBottomViewVisibility() { - canShowButtonOnScroll = true if (layoutManager.findFirstVisibleItemPosition() > 1) { jumpToBottomView.show() } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt index ecbea133df..2ed3bf8614 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt @@ -18,10 +18,12 @@ package im.vector.app.features.home.room.detail import android.content.Context import android.content.Intent +import android.graphics.Color import android.os.Bundle import android.view.View import android.widget.Toast import androidx.core.view.GravityCompat +import androidx.core.view.WindowCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -98,6 +100,11 @@ class RoomDetailActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // For dealing with insets and status bar background color + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) waitingView = views.waitingView.waitingView val timelineArgs: TimelineArgs = intent?.extras?.getParcelableCompat(EXTRA_ROOM_DETAIL_ARGS) ?: return diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index e1392b7580..9bed0aae04 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -35,16 +35,17 @@ import android.widget.TextView import androidx.activity.addCallback import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder -import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri import androidx.core.text.toSpannable import androidx.core.util.Pair import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.forEach import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper @@ -67,7 +68,6 @@ import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelperFactory import im.vector.app.core.epoxy.LayoutManagerStateRestorer -import im.vector.app.core.extensions.animateLayoutChange import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.containsRtLOverride @@ -187,9 +187,7 @@ import im.vector.app.features.widgets.WidgetArgs import im.vector.app.features.widgets.WidgetKind import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -418,20 +416,12 @@ class TimelineFragment : } } - if (savedInstanceState == null) { - handleSpaceShare() + ViewCompat.setOnApplyWindowInsetsListener(views.coordinatorLayout) { _, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()) + views.appBarLayout.updatePadding(top = imeInsets.top) + views.voiceMessageRecorderContainer.updatePadding(bottom = imeInsets.bottom) + insets } - - views.scrim.setOnClickListener { - messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) - } - - messageComposerViewModel.stateFlow.map { it.isFullScreen } - .distinctUntilChanged() - .onEach { isFullScreen -> - toggleFullScreenEditor(isFullScreen) - } - .launchIn(viewLifecycleOwner.lifecycleScope) } private fun setupBackPressHandling() { @@ -1048,13 +1038,7 @@ class TimelineFragment : override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) updateJumpToReadMarkerViewVisibility() - withState(messageComposerViewModel) { composerState -> - if (!composerState.isFullScreen) { - jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() - } else { - jumpToBottomViewVisibilityManager.hideAndPreventVisibilityChangesWithScrolling() - } - } + jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() } }.apply { // For local rooms, pin the view's content to the top edge (the layout is reversed) @@ -1170,7 +1154,6 @@ class TimelineFragment : if (mainState.tombstoneEvent == null) { views.composerContainer.isInvisible = !messageComposerState.isComposerVisible views.voiceMessageRecorderContainer.isVisible = messageComposerState.isVoiceMessageRecorderVisible - when (messageComposerState.canSendMessage) { CanSendStatus.Allowed -> { NotificationAreaView.State.Hidden @@ -2036,19 +2019,6 @@ class TimelineFragment : } } - private fun toggleFullScreenEditor(isFullScreen: Boolean) { - views.composerContainer.animateLayoutChange(200) - - val constraintSet = ConstraintSet() - val constraintSetId = if (isFullScreen) { - R.layout.fragment_timeline_fullscreen - } else { - R.layout.fragment_timeline - } - constraintSet.clone(requireContext(), constraintSetId) - constraintSet.applyTo(views.rootConstraintLayout) - } - /** * Returns true if the current room is a Thread room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 30437a016d..ffaaa235cf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -33,7 +33,6 @@ sealed class MessageComposerAction : VectorViewModelAction { data class OnEntersBackground(val composerText: String) : MessageComposerAction() data class SlashCommandConfirmed(val parsedCommand: ParsedCommand) : MessageComposerAction() data class InsertUserDisplayName(val userId: String) : MessageComposerAction() - data class SetFullScreen(val isFullScreen: Boolean) : MessageComposerAction() // Voice Message diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index aaf63d7f41..d551850ff3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -24,7 +24,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Spannable -import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -32,10 +31,7 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.Toast -import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.view.isGone import androidx.core.view.isInvisible @@ -51,7 +47,6 @@ import com.vanniktech.emoji.EmojiPopup import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.error.fatalError -import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.glide.GlideApp @@ -59,7 +54,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.lifecycleAwareLazy import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.resources.BuildMeta -import im.vector.app.core.utils.DimensionConverter +import im.vector.app.core.utils.ExpandingBottomSheetBehavior import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.onPermissionDeniedDialog import im.vector.app.core.utils.registerForPermissionsResult @@ -86,14 +81,9 @@ import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAc import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel -import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider -import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet -import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.PillImageSpan -import im.vector.app.features.html.PillsPostProcessor import im.vector.app.features.location.LocationSharingMode -import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.poll.PollMode import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.share.SharedData @@ -104,18 +94,9 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import org.commonmark.parser.Parser import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData -import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import org.matrix.android.sdk.api.session.room.model.message.MessageContent -import org.matrix.android.sdk.api.session.room.model.message.MessageFormat -import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent -import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.MatrixItem -import org.matrix.android.sdk.api.util.toMatrixItem import reactivecircus.flowbinding.android.view.focusChanges import reactivecircus.flowbinding.android.widget.textChanges import timber.log.Timber @@ -130,12 +111,7 @@ class MessageComposerFragment : VectorBaseFragment(), A @Inject lateinit var autoCompleterFactory: AutoCompleter.Factory @Inject lateinit var avatarRenderer: AvatarRenderer - @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider - @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer - @Inject lateinit var dimensionConverter: DimensionConverter - @Inject lateinit var imageContentRenderer: ImageContentRenderer @Inject lateinit var shareIntentHandler: ShareIntentHandler - @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory @Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var buildMeta: BuildMeta @@ -147,10 +123,6 @@ class MessageComposerFragment : VectorBaseFragment(), A autoCompleterFactory.create(roomId, isThreadTimeLine()) } - private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(roomId) - } - private val emojiPopup: EmojiPopup by lifecycleAwareLazy { createEmojiPopup() } @@ -166,6 +138,7 @@ class MessageComposerFragment : VectorBaseFragment(), A private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView + private var bottomSheetBehavior: ExpandingBottomSheetBehavior? = null private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() @@ -192,6 +165,7 @@ class MessageComposerFragment : VectorBaseFragment(), A attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() + setupBottomSheet() setupComposer() setupEmojiButton() @@ -217,22 +191,15 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - messageComposerViewModel.stateFlow.map { it.isFullScreen } - .distinctUntilChanged() - .onEach { isFullScreen -> - composer.toggleFullScreen(isFullScreen) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> if (!canSend.boolean()) { return@onEach } when (mode) { is SendMode.Regular -> renderRegularMode(mode.text.toString()) - is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text.toString()) - is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text.toString()) - is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text.toString()) + is SendMode.Edit -> renderSpecialMode(MessageComposerMode.Edit(mode.timelineEvent, mode.text.toString())) + is SendMode.Quote -> renderSpecialMode(MessageComposerMode.Quote(mode.timelineEvent, mode.text.toString())) + is SendMode.Reply -> renderSpecialMode(MessageComposerMode.Reply(mode.timelineEvent, mode.text.toString())) is SendMode.Voice -> renderVoiceMessageMode(mode.text) } } @@ -242,6 +209,14 @@ class MessageComposerFragment : VectorBaseFragment(), A .onEach { onTypeSelected(it.attachmentType) } .launchIn(lifecycleScope) + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + val state = if (isFullScreen) ExpandingBottomSheetBehavior.State.Expanded else ExpandingBottomSheetBehavior.State.Collapsed + bottomSheetBehavior?.setState(state) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + if (savedInstanceState == null) { handleShareData() } @@ -280,11 +255,45 @@ class MessageComposerFragment : VectorBaseFragment(), A ) { mainState, messageComposerState, attachmentState -> if (mainState.tombstoneEvent != null) return@withState - composer.setInvisible(!messageComposerState.isComposerVisible) + (composer as? View)?.isInvisible = !messageComposerState.isComposerVisible composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible (composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled } + private fun setupBottomSheet() { + val parentView = view?.parent as? View ?: return + bottomSheetBehavior = ExpandingBottomSheetBehavior.from(parentView)?.apply { + applyInsetsToContentViewWhenCollapsed = true + topOffset = 22 + useScrimView = true + scrimViewTranslationZ = 8 + minCollapsedHeight = { + (composer as? RichTextComposerLayout)?.estimateCollapsedHeight() ?: -1 + } + isDraggable = false + callback = object : ExpandingBottomSheetBehavior.Callback { + override fun onStateChanged(state: ExpandingBottomSheetBehavior.State) { + // Dragging is disabled while the composer is collapsed + bottomSheetBehavior?.isDraggable = state != ExpandingBottomSheetBehavior.State.Collapsed + + val setFullScreen = when (state) { + ExpandingBottomSheetBehavior.State.Collapsed -> false + ExpandingBottomSheetBehavior.State.Expanded -> true + else -> return + } + + (composer as? RichTextComposerLayout)?.setFullScreen(setFullScreen) + + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(setFullScreen)) + } + + override fun onSlidePositionChanged(view: View, yPosition: Float) { + (composer as? RichTextComposerLayout)?.notifyIsBeingDragged(yPosition) + } + } + } + } + private fun setupComposer() { val composerEditText = composer.editText composerEditText.setHint(R.string.room_message_placeholder) @@ -382,8 +391,7 @@ class MessageComposerFragment : VectorBaseFragment(), A return } if (text.isNotBlank()) { - // We collapse ASAP, if not there will be a slight annoying delay - composer.collapse(true) + composer.renderComposerMode(MessageComposerMode.Normal("")) lockSendButton = true if (formattedText != null) { messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, formattedText, false)) @@ -407,66 +415,12 @@ class MessageComposerFragment : VectorBaseFragment(), A private fun renderRegularMode(content: CharSequence) { autoCompleter.exitSpecialMode() - composer.collapse() - composer.setTextIfDifferent(content) - composer.sendButton.contentDescription = getString(R.string.action_send) + composer.renderComposerMode(MessageComposerMode.Normal(content)) } - private fun renderSpecialMode( - event: TimelineEvent, - @DrawableRes iconRes: Int, - @StringRes descriptionRes: Int, - defaultContent: CharSequence, - ) { + private fun renderSpecialMode(mode: MessageComposerMode.Special) { autoCompleter.enterSpecialMode() - // switch to expanded bar - composer.composerRelatedMessageTitle.apply { - text = event.senderInfo.disambiguatedDisplayName - setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) - } - - val messageContent: MessageContent? = event.getVectorLastMessageContent() - val nonFormattedBody = when (messageContent) { - is MessageAudioContent -> getAudioContentBodyText(messageContent) - is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() - is MessageBeaconInfoContent -> getString(R.string.live_location_description) - else -> messageContent?.body.orEmpty() - } - var formattedBody: CharSequence? = null - if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { - val parser = Parser.builder().build() - val document = parser.parse(messageContent.formattedBody ?: messageContent.body) - formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) - } - composer.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) - - // Image Event - val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) - val isImageVisible = if (data != null) { - imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, composer.composerRelatedMessageImage) - true - } else { - imageContentRenderer.clear(composer.composerRelatedMessageImage) - false - } - - composer.composerRelatedMessageImage.isVisible = isImageVisible - - composer.replaceFormattedContent(defaultContent) - - composer.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) - composer.sendButton.contentDescription = getString(descriptionRes) - - avatarRenderer.render(event.senderInfo.toMatrixItem(), composer.composerRelatedMessageAvatar) - - composer.expand { - if (isAdded) { - // need to do it here also when not using quick reply - focusComposerAndShowKeyboard() - composer.composerRelatedMessageImage.isVisible = isImageVisible - } - } - focusComposerAndShowKeyboard() + composer.renderComposerMode(mode) } private fun observerUserTyping() { @@ -489,7 +443,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun focusComposerAndShowKeyboard() { - if (composer.isVisible) { + if ((composer as? View)?.isVisible == true) { composer.editText.showKeyboard(andRequestFocus = true) } } @@ -499,7 +453,7 @@ class MessageComposerFragment : VectorBaseFragment(), A composer.sendButton.alpha = 0f composer.sendButton.isVisible = true composer.sendButton.animate().alpha(1f).setDuration(150).start() - } else if (!event.isVisible) { + } else { composer.sendButton.isInvisible = true } } @@ -510,15 +464,6 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { - val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) - return if (messageContent.voiceMessageIndicator != null) { - getString(R.string.voice_message_reply_content, formattedDuration) - } else { - getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) - } - } - private fun createEmojiPopup(): EmojiPopup { return EmojiPopup( rootView = views.root, @@ -840,11 +785,6 @@ class MessageComposerFragment : VectorBaseFragment(), A return displayName } - /** - * Returns the root thread event if we are in a thread room, otherwise returns null. - */ - fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } - /** * Returns true if the current room is a Thread room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt new file mode 100644 index 0000000000..a401f04bf5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 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.app.features.home.room.detail.composer + +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +sealed interface MessageComposerMode { + data class Normal(val content: CharSequence?) : MessageComposerMode + + sealed class Special(open val event: TimelineEvent, open val defaultContent: CharSequence) : MessageComposerMode + data class Edit(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + class Quote(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + class Reply(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index b7e0e29679..44fcf22d4a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -19,35 +19,24 @@ package im.vector.app.features.home.room.detail.composer import android.text.Editable import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView interface MessageComposerView { + companion object { + const val MAX_LINES_WHEN_COLLAPSED = 10 + } + val text: Editable? val formattedText: String? val editText: EditText val emojiButton: ImageButton? val sendButton: ImageButton val attachmentButton: ImageButton - val fullScreenButton: ImageButton? - val composerRelatedMessageTitle: TextView - val composerRelatedMessageContent: TextView - val composerRelatedMessageImage: ImageView - val composerRelatedMessageActionIcon: ImageView - val composerRelatedMessageAvatar: ImageView var callback: Callback? - var isVisible: Boolean - - fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) - fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) fun setTextIfDifferent(text: CharSequence?): Boolean - fun replaceFormattedContent(text: CharSequence) - fun toggleFullScreen(newValue: Boolean) - - fun setInvisible(isInvisible: Boolean) + fun renderComposerMode(mode: MessageComposerMode) } interface Callback : ComposerEditText.Callback { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt index 939a59fcca..8f4dd9b71d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -19,44 +19,59 @@ package im.vector.app.features.home.room.detail.composer import android.content.Context import android.net.Uri import android.text.Editable +import android.text.format.DateUtils import android.util.AttributeSet -import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet +import android.widget.LinearLayout +import androidx.core.content.ContextCompat import androidx.core.text.toSpannable -import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R -import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ComposerLayoutBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData +import im.vector.app.features.html.EventHtmlRenderer +import im.vector.app.features.html.PillsPostProcessor +import im.vector.app.features.media.ImageContentRenderer +import org.commonmark.parser.Parser +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageFormat +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem +import javax.inject.Inject /** * Encapsulate the timeline composer UX. */ +@AndroidEntryPoint class PlainTextComposerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { +) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView { + + @Inject lateinit var avatarRenderer: AvatarRenderer + @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider + @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer + @Inject lateinit var dimensionConverter: DimensionConverter + @Inject lateinit var imageContentRenderer: ImageContentRenderer + @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory private val views: ComposerLayoutBinding override var callback: Callback? = null - private var currentConstraintSetId: Int = -1 - - private val animationDuration = 100L - override val text: Editable? get() = views.composerEditText.text @@ -65,37 +80,23 @@ class PlainTextComposerLayout @JvmOverloads constructor( override val editText: EditText get() = views.composerEditText + @Suppress("RedundantNullableReturnType") override val emojiButton: ImageButton? get() = views.composerEmojiButton override val sendButton: ImageButton get() = views.sendButton - override fun setInvisible(isInvisible: Boolean) { - this.isInvisible = isInvisible - } override val attachmentButton: ImageButton get() = views.attachmentButton - override val fullScreenButton: ImageButton? = null - override val composerRelatedMessageActionIcon: ImageView - get() = views.composerRelatedMessageActionIcon - override val composerRelatedMessageAvatar: ImageView - get() = views.composerRelatedMessageAvatar - override val composerRelatedMessageContent: TextView - get() = views.composerRelatedMessageContent - override val composerRelatedMessageImage: ImageView - get() = views.composerRelatedMessageImage - override val composerRelatedMessageTitle: TextView - get() = views.composerRelatedMessageTitle - override var isVisible: Boolean - get() = views.root.isVisible - set(value) { views.root.isVisible = value } init { inflate(context, R.layout.composer_layout, this) views = ComposerLayoutBinding.bind(this) - collapse(false) + views.composerEditText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED + + collapse() views.composerEditText.callback = object : ComposerEditText.Callback { override fun onRichContentSelected(contentUri: Uri): Boolean { @@ -121,27 +122,15 @@ class PlainTextComposerLayout @JvmOverloads constructor( } } - override fun replaceFormattedContent(text: CharSequence) { - setTextIfDifferent(text) - } - - override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) + private fun collapse(transitionComplete: (() -> Unit)? = null) { + views.relatedMessageGroup.isVisible = false + transitionComplete?.invoke() callback?.onExpandOrCompactChange() } - override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) + private fun expand(transitionComplete: (() -> Unit)? = null) { + views.relatedMessageGroup.isVisible = true + transitionComplete?.invoke() callback?.onExpandOrCompactChange() } @@ -149,35 +138,92 @@ class PlainTextComposerLayout @JvmOverloads constructor( return views.composerEditText.setTextIfDifferent(text) } - override fun toggleFullScreen(newValue: Boolean) { - // Plain text composer has no full screen + override fun renderComposerMode(mode: MessageComposerMode) { + val specialMode = mode as? MessageComposerMode.Special + if (specialMode != null) { + renderSpecialMode(specialMode) + } else if (mode is MessageComposerMode.Normal) { + collapse() + editText.setTextIfDifferent(mode.content) + } + + views.sendButton.apply { + if (mode is MessageComposerMode.Edit) { + contentDescription = resources.getString(R.string.action_save) + setImageResource(R.drawable.ic_composer_rich_text_save) + } else { + contentDescription = resources.getString(R.string.action_send) + setImageResource(R.drawable.ic_rich_composer_send) + } + } } - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - configureAndBeginTransition(transitionComplete) + private fun renderSpecialMode(specialMode: MessageComposerMode.Special) { + val event = specialMode.event + val defaultContent = specialMode.defaultContent + + val iconRes: Int = when (specialMode) { + is MessageComposerMode.Reply -> R.drawable.ic_reply + is MessageComposerMode.Edit -> R.drawable.ic_edit + is MessageComposerMode.Quote -> R.drawable.ic_quote } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) + + val pillsPostProcessor = pillsPostProcessorFactory.create(event.roomId) + + // switch to expanded bar + views.composerRelatedMessageTitle.apply { + text = event.senderInfo.disambiguatedDisplayName + setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) + } + + val messageContent: MessageContent? = event.getVectorLastMessageContent() + val nonFormattedBody = when (messageContent) { + is MessageAudioContent -> getAudioContentBodyText(messageContent) + is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() + is MessageBeaconInfoContent -> resources.getString(R.string.live_location_description) + else -> messageContent?.body.orEmpty() + } + var formattedBody: CharSequence? = null + if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { + val parser = Parser.builder().build() + val document = parser.parse(messageContent.formattedBody ?: messageContent.body) + formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) + } + views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) + + // Image Event + val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) + val isImageVisible = if (data != null) { + imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerRelatedMessageImage) + true + } else { + imageContentRenderer.clear(views.composerRelatedMessageImage) + false + } + + views.composerRelatedMessageImage.isVisible = isImageVisible + + views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(context, iconRes)) + + avatarRenderer.render(event.senderInfo.toMatrixItem(), views.composerRelatedMessageAvatar) + + views.composerEditText.setText(defaultContent) + + expand { + // need to do it here also when not using quick reply + if (isVisible) { + showKeyboard(andRequestFocus = true) + } + views.composerRelatedMessageImage.isVisible = isImageVisible } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible } - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : SimpleTransitionListener() { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - }) + private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { + val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) + return if (messageContent.voiceMessageIndicator != null) { + resources.getString(R.string.voice_message_reply_content, formattedDuration) + } else { + resources.getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 2d2a4a8cd2..85f163360f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -16,25 +16,34 @@ package im.vector.app.features.home.room.detail.composer +import android.annotation.SuppressLint import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.graphics.Color import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet +import android.util.TypedValue import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView +import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet import androidx.core.text.toSpannable +import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.google.android.material.shape.MaterialShapeDrawable import im.vector.app.R -import im.vector.app.core.extensions.animateLayoutChange +import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.core.extensions.showKeyboard import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import io.element.android.wysiwyg.EditorEditText @@ -46,23 +55,22 @@ class RichTextComposerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { +) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView { private val views: ComposerRichTextLayoutBinding override var callback: Callback? = null - private var currentConstraintSetId: Int = -1 - private val animationDuration = 100L - private val maxEditTextLinesWhenCollapsed = 12 - - private val isFullScreen: Boolean get() = currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_fullscreen + // There is no need to persist these values since they're always updated by the parent fragment + private var isFullScreen = false + private var hasRelatedMessage = false var isTextFormattingEnabled = true set(value) { if (field == value) return syncEditTexts() field = value + updateTextFieldBorder(isFullScreen) updateEditTextVisibility() } @@ -82,37 +90,94 @@ class RichTextComposerLayout @JvmOverloads constructor( get() = views.sendButton override val attachmentButton: ImageButton get() = views.attachmentButton - override val fullScreenButton: ImageButton? - get() = views.composerFullScreenButton - override val composerRelatedMessageActionIcon: ImageView - get() = views.composerRelatedMessageActionIcon - override val composerRelatedMessageAvatar: ImageView - get() = views.composerRelatedMessageAvatar - override val composerRelatedMessageContent: TextView - get() = views.composerRelatedMessageContent - override val composerRelatedMessageImage: ImageView - get() = views.composerRelatedMessageImage - override val composerRelatedMessageTitle: TextView - get() = views.composerRelatedMessageTitle - override var isVisible: Boolean - get() = views.root.isVisible - set(value) { views.root.isVisible = value } + + // Border of the EditText + private val borderShapeDrawable: MaterialShapeDrawable by lazy { + MaterialShapeDrawable().apply { + val typedData = TypedValue() + val lineColor = context.theme.obtainStyledAttributes(typedData.data, intArrayOf(R.attr.vctr_content_quaternary)) + .getColor(0, 0) + strokeColor = ColorStateList.valueOf(lineColor) + strokeWidth = 1 * resources.displayMetrics.scaledDensity + fillColor = ColorStateList.valueOf(Color.TRANSPARENT) + val cornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + setCornerSize(cornerSize.toFloat()) + } + } + + fun setFullScreen(isFullScreen: Boolean) { + editText.updateLayoutParams { + height = if (isFullScreen) 0 else ViewGroup.LayoutParams.WRAP_CONTENT + } + + updateTextFieldBorder(isFullScreen) + updateEditTextVisibility() + + updateEditTextFullScreenState(views.richTextComposerEditText, isFullScreen) + updateEditTextFullScreenState(views.plainTextComposerEditText, isFullScreen) + + views.composerFullScreenButton.setImageResource( + if (isFullScreen) R.drawable.ic_composer_collapse else R.drawable.ic_composer_full_screen + ) + + views.bottomSheetHandle.isVisible = isFullScreen + if (isFullScreen) { + editText.showKeyboard(true) + } else { + editText.hideKeyboard() + } + this.isFullScreen = isFullScreen + } + + fun notifyIsBeingDragged(percentage: Float) { + // Calculate a new shape for the border according to the position in screen + val isSingleLine = editText.lineCount == 1 + val cornerSize = if (!isSingleLine || hasRelatedMessage) { + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded).toFloat() + } else { + val multilineCornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) + val singleLineCornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + val diff = singleLineCornerSize - multilineCornerSize + multilineCornerSize + diff * (1 - percentage) + } + if (cornerSize != borderShapeDrawable.bottomLeftCornerResolvedSize) { + borderShapeDrawable.setCornerSize(cornerSize) + } + + // Change maxLines while dragging, this should improve the smoothness of animations + val maxLines = if (percentage > 0.25f) { + Int.MAX_VALUE + } else { + MessageComposerView.MAX_LINES_WHEN_COLLAPSED + } + views.richTextComposerEditText.maxLines = maxLines + views.plainTextComposerEditText.maxLines = maxLines + + views.bottomSheetHandle.isVisible = true + } init { inflate(context, R.layout.composer_rich_text_layout, this) views = ComposerRichTextLayoutBinding.bind(this) - collapse(false) + // Workaround to avoid cut-off text caused by padding in scrolled TextView (there is no clipToPadding). + // In TextView, clipTop = padding, but also clipTop -= shadowRadius. So if we set the shadowRadius to padding, they cancel each other + views.richTextComposerEditText.setShadowLayer(views.richTextComposerEditText.paddingBottom.toFloat(), 0f, 0f, 0) + views.plainTextComposerEditText.setShadowLayer(views.richTextComposerEditText.paddingBottom.toFloat(), 0f, 0f, 0) + + renderComposerMode(MessageComposerMode.Normal(null)) views.richTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) ) views.plainTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) ) - views.composerRelatedMessageCloseButton.setOnClickListener { - collapse() + disallowParentInterceptTouchEvent(views.richTextComposerEditText) + disallowParentInterceptTouchEvent(views.plainTextComposerEditText) + + views.composerModeCloseView.setOnClickListener { callback?.onCloseRelatedMessage() } @@ -125,11 +190,19 @@ class RichTextComposerLayout @JvmOverloads constructor( callback?.onAddAttachment() } - views.composerFullScreenButton.setOnClickListener { - callback?.onFullScreenModeChanged() + views.composerFullScreenButton.apply { + // There's no point in having full screen in landscape since there's almost no vertical space + isInvisible = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + setOnClickListener { + callback?.onFullScreenModeChanged() + } } + views.composerEditTextOuterBorder.background = borderShapeDrawable + setupRichTextMenu() + + updateTextFieldBorder(isFullScreen) } private fun setupRichTextMenu() { @@ -147,6 +220,21 @@ class RichTextComposerLayout @JvmOverloads constructor( } } + @SuppressLint("ClickableViewAccessibility") + private fun disallowParentInterceptTouchEvent(view: View) { + view.setOnTouchListener { v, event -> + if (v.hasFocus()) { + v.parent?.requestDisallowInterceptTouchEvent(true) + val action = event.actionMasked + if (action == MotionEvent.ACTION_SCROLL) { + v.parent?.requestDisallowInterceptTouchEvent(false) + return@setOnTouchListener true + } + } + false + } + } + override fun onAttachedToWindow() { super.onAttachedToWindow() @@ -197,84 +285,99 @@ class RichTextComposerLayout @JvmOverloads constructor( button.isSelected = menuState.reversedActions.contains(action) } - private fun updateTextFieldBorder() { - val isExpanded = editText.editableText.lines().count() > 1 - val borderResource = if (isExpanded || isFullScreen) { - R.drawable.bg_composer_rich_edit_text_expanded + fun estimateCollapsedHeight(): Int { + val editText = this.editText + val originalLines = editText.maxLines + val originalParamsHeight = editText.layoutParams.height + editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED + editText.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.UNSPECIFIED, + ) + val result = measuredHeight + editText.layoutParams.height = originalParamsHeight + editText.maxLines = originalLines + return result + } + + private fun updateTextFieldBorder(isFullScreen: Boolean) { + val isMultiline = editText.editableText.lines().count() > 1 || isFullScreen || hasRelatedMessage + val cornerSize = if (isMultiline) { + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) } else { - R.drawable.bg_composer_rich_edit_text_single_line - } - views.composerEditTextOuterBorder.setBackgroundResource(borderResource) + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + }.toFloat() + borderShapeDrawable.setCornerSize(cornerSize) } - override fun replaceFormattedContent(text: CharSequence) { + private fun replaceFormattedContent(text: CharSequence) { views.richTextComposerEditText.setHtml(text.toString()) - } - - override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) - updateEditTextVisibility() - } - - override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) - updateEditTextVisibility() + updateTextFieldBorder(isFullScreen) } override fun setTextIfDifferent(text: CharSequence?): Boolean { - return editText.setTextIfDifferent(text) - } - - override fun toggleFullScreen(newValue: Boolean) { - val constraintSetId = if (newValue) R.layout.composer_rich_text_layout_constraint_set_fullscreen else currentConstraintSetId - ConstraintSet().also { - it.clone(context, constraintSetId) - it.applyTo(this) - } - - updateTextFieldBorder() - updateEditTextVisibility() - - updateEditTextFullScreenState(views.richTextComposerEditText, newValue) - updateEditTextFullScreenState(views.plainTextComposerEditText, newValue) + val result = editText.setTextIfDifferent(text) + updateTextFieldBorder(isFullScreen) + return result } private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) { if (isFullScreen) { editText.maxLines = Int.MAX_VALUE - // This is a workaround to fix incorrect scroll position when maximised - post { editText.requestLayout() } } else { - editText.maxLines = maxEditTextLinesWhenCollapsed + editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED } } - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - animateLayoutChange(animationDuration, transitionComplete) - } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) + override fun renderComposerMode(mode: MessageComposerMode) { + if (mode is MessageComposerMode.Special) { + views.composerModeGroup.isVisible = true + replaceFormattedContent(mode.defaultContent) + hasRelatedMessage = true + editText.showKeyboard(andRequestFocus = true) + } else { + views.composerModeGroup.isGone = true + (mode as? MessageComposerMode.Normal)?.content?.let { text -> + if (isTextFormattingEnabled) { + replaceFormattedContent(text) + } else { + views.plainTextComposerEditText.setText(text) + } + } + views.sendButton.contentDescription = resources.getString(R.string.action_send) + hasRelatedMessage = false } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible - } + views.sendButton.apply { + if (mode is MessageComposerMode.Edit) { + contentDescription = resources.getString(R.string.action_save) + setImageResource(R.drawable.ic_composer_rich_text_save) + } else { + contentDescription = resources.getString(R.string.action_send) + setImageResource(R.drawable.ic_rich_composer_send) + } + } - override fun setInvisible(isInvisible: Boolean) { - this.isInvisible = isInvisible + updateTextFieldBorder(isFullScreen) + + when (mode) { + is MessageComposerMode.Edit -> { + views.composerModeTitleView.setText(R.string.editing) + views.composerModeIconView.setImageResource(R.drawable.ic_composer_rich_text_editor_edit) + } + is MessageComposerMode.Quote -> { + views.composerModeTitleView.setText(R.string.quoting) + views.composerModeIconView.setImageResource(R.drawable.ic_quote) + } + is MessageComposerMode.Reply -> { + val senderInfo = mode.event.senderInfo + val userName = senderInfo.displayName ?: senderInfo.disambiguatedDisplayName + views.composerModeTitleView.text = resources.getString(R.string.replying_to, userName) + views.composerModeIconView.setImageResource(R.drawable.ic_reply) + } + else -> Unit + } } private class TextChangeListener( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt index cd41219371..ca31c53bb3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt @@ -147,7 +147,8 @@ class VoiceMessageViews( } fun showRecordingViews() { - views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording) + views.voiceMessageBackgroundView.isVisible = true + views.voiceMessageMicButton.setImageResource(R.drawable.ic_composer_rich_mic_pressed) views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary) views.voiceMessageMicButton.updateLayoutParams { setMargins(0, 0, 0, 0) @@ -172,6 +173,7 @@ class VoiceMessageViews( fun hideRecordingViews(recordingState: RecordingUiState) { // We need to animate the lock image first + views.voiceMessageBackgroundView.isVisible = false if (recordingState !is RecordingUiState.Locked) { views.voiceMessageLockImage.isVisible = false views.voiceMessageLockImage.animate().translationY(0f).start() @@ -278,6 +280,7 @@ class VoiceMessageViews( fun showDraftViews() { hideRecordingViews(RecordingUiState.Idle) + views.voiceMessageBackgroundView.isVisible = true views.voiceMessageMicButton.isVisible = false views.voiceMessageSendButton.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true @@ -288,6 +291,7 @@ class VoiceMessageViews( fun showRecordingLockedViews(recordingState: RecordingUiState) { hideRecordingViews(recordingState) + views.voiceMessageBackgroundView.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true views.voiceMessagePlaybackTimerIndicator.isVisible = true views.voicePlaybackControlButton.isVisible = false diff --git a/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml b/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml new file mode 100644 index 0000000000..47364373f7 --- /dev/null +++ b/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml b/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml deleted file mode 100644 index 26d997e7db..0000000000 --- a/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml b/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml deleted file mode 100644 index 7e2745a137..0000000000 --- a/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/vector/src/main/res/drawable/bottomsheet_handle.xml b/vector/src/main/res/drawable/bottomsheet_handle.xml new file mode 100644 index 0000000000..89ccf57ed0 --- /dev/null +++ b/vector/src/main/res/drawable/bottomsheet_handle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_composer_collapse.xml b/vector/src/main/res/drawable/ic_composer_collapse.xml new file mode 100644 index 0000000000..724a833761 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_collapse.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_full_screen.xml b/vector/src/main/res/drawable/ic_composer_full_screen.xml index 394dc52279..de1862c09b 100644 --- a/vector/src/main/res/drawable/ic_composer_full_screen.xml +++ b/vector/src/main/res/drawable/ic_composer_full_screen.xml @@ -1,9 +1,9 @@ - + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + diff --git a/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml b/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml new file mode 100644 index 0000000000..e9dbe610e4 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml b/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml new file mode 100644 index 0000000000..c461470de5 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml b/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml new file mode 100644 index 0000000000..4556974221 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_save.xml b/vector/src/main/res/drawable/ic_composer_rich_text_save.xml new file mode 100644 index 0000000000..f270d6f8ae --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_save.xml @@ -0,0 +1,16 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_rich_composer_add.xml b/vector/src/main/res/drawable/ic_rich_composer_add.xml new file mode 100644 index 0000000000..3a90a40902 --- /dev/null +++ b/vector/src/main/res/drawable/ic_rich_composer_add.xml @@ -0,0 +1,15 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_rich_composer_send.xml b/vector/src/main/res/drawable/ic_rich_composer_send.xml new file mode 100644 index 0000000000..0f99c1670e --- /dev/null +++ b/vector/src/main/res/drawable/ic_rich_composer_send.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_voice_mic_recording.xml b/vector/src/main/res/drawable/ic_voice_mic_recording.xml deleted file mode 100644 index a57852c92f..0000000000 --- a/vector/src/main/res/drawable/ic_voice_mic_recording.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/vector/src/main/res/layout/composer_layout.xml b/vector/src/main/res/layout/composer_layout.xml index fb0d80278a..7c465891c3 100644 --- a/vector/src/main/res/layout/composer_layout.xml +++ b/vector/src/main/res/layout/composer_layout.xml @@ -1,148 +1,210 @@ - + android:orientation="vertical"> - - - - - - - - - - + android:visibility="gone" + tools:visibility="visible"> - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml deleted file mode 100644 index 81b978caa6..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml deleted file mode 100644 index 8cdb388bf9..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index c5afe1eb44..5f37de2a3a 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -1,183 +1,201 @@ - - - - - - - - - - - - - - - - - - - - - - + android:orientation="vertical" + android:background="@drawable/bg_composer_rich_bottom_sheet"> - - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> - - + - + - + - + - + + + + + android:layout_marginStart="6dp" + android:layout_marginTop="8dp" + android:paddingBottom="2dp" + android:fontFamily="sans-serif-medium" + tools:text="Editing" + style="@style/BottomSheetItemTime" + app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder" + app:layout_constraintStart_toEndOf="@id/composerModeIconView" /> - + - + - + - + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml deleted file mode 100644 index 1a3023a805..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml +++ /dev/null @@ -1,233 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml deleted file mode 100644 index b0380d2e13..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,230 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml deleted file mode 100644 index 3105063933..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml +++ /dev/null @@ -1,234 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml index 41c052367a..5038a9e179 100644 --- a/vector/src/main/res/layout/fragment_composer.xml +++ b/vector/src/main/res/layout/fragment_composer.xml @@ -4,12 +4,13 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:background="@android:color/transparent"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@+id/rootConstraintLayout"> - + app:layout_constraintTop_toTopOf="parent" /> - + + + + + app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView" + tools:listitem="@layout/item_timeline_event_base" /> + + + + + + + + + + + + + + + + + + + + + + + - + + + + + diff --git a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml b/vector/src/main/res/layout/fragment_timeline_fullscreen.xml deleted file mode 100644 index 373ca74f56..0000000000 --- a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml +++ /dev/null @@ -1,258 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/view_rich_text_menu_button.xml b/vector/src/main/res/layout/view_rich_text_menu_button.xml index 24b19c10b5..b99a29da2b 100644 --- a/vector/src/main/res/layout/view_rich_text_menu_button.xml +++ b/vector/src/main/res/layout/view_rich_text_menu_button.xml @@ -2,8 +2,8 @@ + + @@ -109,7 +118,7 @@ android:id="@+id/voiceMessageLockImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="28dp" + android:layout_marginTop="16dp" android:importantForAccessibility="no" android:src="@drawable/ic_voice_message_unlocked" android:visibility="gone" @@ -123,7 +132,6 @@ android:id="@+id/voiceMessageLockArrow" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="4dp" android:layout_marginBottom="14dp" android:importantForAccessibility="no" android:src="@drawable/ic_voice_lock_arrow"