added coach mark view

This commit is contained in:
NIkita Fedrunov 2022-05-12 16:44:28 +02:00
parent 9a756f2b7a
commit 3dc0ef75ba
11 changed files with 303 additions and 3 deletions

View File

@ -0,0 +1,187 @@
/*
* 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.core.ui.views
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.transition.Fade
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import im.vector.app.R
/***
* Coach mark widget, which could be shown against any part of layout to provide user with valuable information about it
* It shows a popup dialog which covers entire screen and consume first click on any location on the screen to dismiss itself
* @param context the context
* @param root fragment or activity root view to host popup dialog
*/
class CoachMarkView(val context: Context, val root: View) {
@SuppressLint("InflateParams")
private val view: View = LayoutInflater.from(context).inflate(R.layout.coach_mark, null)
private val markView: ViewGroup = view.findViewById(R.id.coach_mark)
companion object {
/**
* Coach mark pointers should not be positioned on top of rounded corners to prevent gaps between background shape and pointer
* This constant must match `radius` property of coach mark's background shape
* */
private const val ANCHOR_MIN_HORIZONTAL_MARGIN_DP = 12f
}
/***
* Show coach mark for specified anchor view
*
* @param stringId string resource id for text to be shown
* @param anchor view which will be pointed by coach mark
* @param gravity coach mark gravity. [Gravity.BOTTOM] to show mark below anchor, [Gravity.TOP] to show above.
*/
fun show(@StringRes stringId: Int, anchor: View, gravity: Int = Gravity.NO_GRAVITY, onDismiss: (() -> Unit)? = null) {
show(context.resources.getString(stringId), anchor, gravity, onDismiss)
}
/***
* Show coach mark for specified anchor view
*
* @param text text to be shown
* @param anchor view which will be pointed by coach mark
* @param gravity coach mark gravity. [Gravity.BOTTOM] to show mark below anchor, [Gravity.TOP] to show above.
*/
fun show(text: CharSequence, anchor: View, gravity: Int = Gravity.NO_GRAVITY, onDismiss: (() -> Unit)? = null) {
view.findViewById<TextView>(R.id.coach_mark_content).text = text
val width = context.resources.displayMetrics.widthPixels - markView.paddingLeft - markView.paddingRight
markView.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(context.resources.displayMetrics.heightPixels, View.MeasureSpec.AT_MOST)
)
val anchorDimens = getAnchorDimens(anchor, root)
setMarkVerticalPosition(context, anchorDimens, view, markView.measuredHeight, gravity)
setPointerHorizontalPosition(anchorDimens, markView)
createPopupWindow(view, onDismiss).showAtLocation(root, Gravity.NO_GRAVITY, 0, 0)
}
private fun setMarkVerticalPosition(context: Context, anchorDimens: Dimens, view: View, markHeight: Int, gravity: Int) {
val maxHeight = context.resources.displayMetrics.heightPixels
val resolvedGravity = resolveGravity(
maxHeight = maxHeight,
anchorDimens = anchorDimens,
markHeight = markHeight,
gravity = gravity
)
view.findViewById<View>(R.id.coach_mark_arrow_top).isVisible = resolvedGravity == Gravity.BOTTOM
view.findViewById<View>(R.id.coach_mark_arrow_bottom).isVisible = resolvedGravity == Gravity.TOP
val markY = when (resolvedGravity) {
Gravity.TOP -> anchorDimens.y - markHeight
Gravity.BOTTOM -> anchorDimens.y + anchorDimens.height
else -> maxHeight / 2 - markHeight / 2
}
setMarkY(markY, view)
}
private fun resolveGravity(maxHeight: Int, anchorDimens: Dimens, markHeight: Int, gravity: Int): Int {
val topSpace = anchorDimens.y
val bottomSpace = maxHeight - (anchorDimens.y + anchorDimens.height)
val fitAbove = topSpace >= markHeight
val fitBelow = bottomSpace >= markHeight
if (!fitAbove && !fitBelow) {
return Gravity.CENTER
}
return when {
gravity == Gravity.TOP && fitAbove -> Gravity.TOP
gravity == Gravity.BOTTOM && fitBelow -> Gravity.BOTTOM
bottomSpace >= topSpace -> Gravity.BOTTOM //choose the bigger side if required gravity isn't provided or can't be applied
else -> Gravity.TOP
}
}
private fun setMarkY(y: Int, markView: View) {
val mark = markView.findViewById<View>(R.id.coach_mark)
val params = (mark.layoutParams as ViewGroup.MarginLayoutParams)
params.setMargins(0, y, 0, 0)
mark.layoutParams = params
}
@SuppressLint("ClickableViewAccessibility")
private fun createPopupWindow(containerView: View, onDismiss: (() -> Unit)?): PopupWindow =
PopupWindow(containerView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
enterTransition = Fade(Fade.IN)
exitTransition = Fade(Fade.OUT)
}
isTouchable = true
setTouchInterceptor { _, _ ->
dismiss()
onDismiss?.invoke()
true
}
}
private fun setPointerHorizontalPosition(anchorDimens: Dimens, markView: View) {
val topArrow = markView.findViewById<View>(R.id.coach_mark_arrow_top)
val bottomArrow = markView.findViewById<View>(R.id.coach_mark_arrow_bottom)
val paddingHorizontal = markView.paddingStart
val anchorCenter = anchorDimens.x + anchorDimens.width / 2
val minHorizontalMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
ANCHOR_MIN_HORIZONTAL_MARGIN_DP,
markView.context.resources.displayMetrics
).toInt()
var leftMargin = anchorCenter - paddingHorizontal - topArrow.measuredWidth / 2
leftMargin = leftMargin.coerceAtLeast(minHorizontalMargin)
(topArrow.layoutParams as ViewGroup.MarginLayoutParams).leftMargin = leftMargin
(bottomArrow.layoutParams as ViewGroup.MarginLayoutParams).leftMargin = leftMargin
}
private fun getAnchorDimens(anchor: View, parent: View): Dimens {
val rootViewLoc = IntArray(2)
parent.getLocationOnScreen(rootViewLoc)
val anchorLoc = IntArray(2)
anchor.getLocationOnScreen(anchorLoc)
anchorLoc[1] -= rootViewLoc[1]
return Dimens(anchorLoc[0], anchorLoc[1], anchor.measuredWidth, anchor.measuredHeight)
}
private data class Dimens(val x: Int, val y: Int, val width: Int, val height: Int)
}

View File

@ -205,6 +205,8 @@ class VectorPreferences @Inject constructor(
private const val SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE = "SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE"
private const val SETTINGS_LABS_ENABLE_LIVE_LOCATION = "SETTINGS_LABS_ENABLE_LIVE_LOCATION"
private const val DID_SHOW_USER_SPACE_LEAVE_COACH_MARK = "DID_SHOW_USER_SPACE_LEAVE_COACH_MARK"
// This key will be used to identify clients with the old thread support enabled io.element.thread
const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES"
@ -1102,4 +1104,14 @@ class VectorPreferences @Inject constructor(
fun showLiveSenderInfo(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO, getDefault(R.bool.settings_timeline_show_live_sender_info_default))
}
fun didShowUserSpaceLeaveCoachMark(): Boolean {
return defaultPrefs.getBoolean(DID_SHOW_USER_SPACE_LEAVE_COACH_MARK, false)
}
fun setDidShowUserSpaceLeaveCoachMark() {
defaultPrefs.edit {
putBoolean(DID_SHOW_USER_SPACE_LEAVE_COACH_MARK, true)
}
}
}

View File

@ -26,4 +26,5 @@ sealed class SpaceLeaveAdvanceViewAction : VectorViewModelAction {
object ClearError : SpaceLeaveAdvanceViewAction()
object SelectAll : SpaceLeaveAdvanceViewAction()
object SelectNone : SpaceLeaveAdvanceViewAction()
object CoachMarkDismissed : SpaceLeaveAdvanceViewAction()
}

View File

@ -30,7 +30,8 @@ data class SpaceLeaveAdvanceViewState(
val currentFilter: String = "",
val leaveState: Async<Unit> = Uninitialized,
val isFilteringEnabled: Boolean = false,
val isLastAdmin: Boolean = false
val isLastAdmin: Boolean = false,
val showCoachMark: Boolean = false
) : MavericksState {
constructor(args: SpaceBottomSheetSettingsArgs) : this(

View File

@ -17,6 +17,7 @@
package im.vector.app.features.spaces.leave
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
@ -32,6 +33,7 @@ import im.vector.app.R
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.ui.views.CoachMarkView
import im.vector.app.core.utils.ToggleableAppBarLayoutBehavior
import im.vector.app.databinding.FragmentSpaceLeaveAdvancedBinding
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@ -113,6 +115,12 @@ class SpaceLeaveAdvancedFragment @Inject constructor(
views.appBarLayout.setExpanded(false)
}
if (state.showCoachMark) {
CoachMarkView(requireContext(), views.root).show(R.string.space_leave_coach_mark_text, views.coachMarkGuide, Gravity.BOTTOM) {
viewModel.handle(SpaceLeaveAdvanceViewAction.CoachMarkDismissed)
}
}
updateAppBarBehaviorState(state)
updateRadioButtonsState(state)

View File

@ -29,6 +29,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.settings.VectorPreferences
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -52,7 +53,8 @@ import timber.log.Timber
class SpaceLeaveAdvancedViewModel @AssistedInject constructor(
@Assisted val initialState: SpaceLeaveAdvanceViewState,
private val session: Session,
private val appStateHandler: AppStateHandler
private val appStateHandler: AppStateHandler,
private val vectorPreferences: VectorPreferences
) : VectorViewModel<SpaceLeaveAdvanceViewState, SpaceLeaveAdvanceViewAction, EmptyViewEvents>(initialState) {
init {
@ -99,7 +101,7 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor(
)
setState {
copy(allChildren = Success(children))
copy(allChildren = Success(children), showCoachMark = !vectorPreferences.didShowUserSpaceLeaveCoachMark())
}
}
}
@ -113,9 +115,15 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor(
is SpaceLeaveAdvanceViewAction.ToggleSelection -> handleSelectionToggle(action)
SpaceLeaveAdvanceViewAction.DoLeave -> handleLeave()
SpaceLeaveAdvanceViewAction.SelectAll -> handleSelectAll()
SpaceLeaveAdvanceViewAction.CoachMarkDismissed -> handleCoachMarkDismissed()
}
}
private fun handleCoachMarkDismissed() = withState {
vectorPreferences.setDidShowUserSpaceLeaveCoachMark()
setState { copy(showCoachMark = false) }
}
private fun handleSelectAll() = withState { state ->
val filteredRooms = (state.allChildren as? Success)?.invoke()?.filter {
it.name.contains(state.currentFilter, true)

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?colorPrimary" />
<corners android:radius="12dp" />
</shape>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="21dp"
android:height="14dp"
android:viewportWidth="21"
android:viewportHeight="14">
<path
android:pathData="M10.5,0L20.459,13.5L0.541,13.5L10.5,0Z"
android:fillColor="#0DBD8B"/>
</vector>

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/coach_mark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|top"
android:paddingHorizontal="20dp"
android:orientation="vertical"
tools:ignore="UselessParent">
<ImageView
android:id="@+id/coach_mark_arrow_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="-1dp"
android:scaleType="fitXY"
android:src="@drawable/ic_coach_arrow"
app:tint="?colorPrimary"
tools:ignore="ContentDescription"/>
<TextView
android:id="@+id/coach_mark_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_coach_mark"
android:backgroundTint="?colorPrimary"
android:gravity="center"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:textAppearance="@style/TextAppearance.Vector.Body"
android:textSize="16sp"
android:textColor="@color/palette_white"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua" />
<ImageView
android:id="@+id/coach_mark_arrow_bottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="-1dp"
android:rotation="180"
android:scaleType="fitXY"
android:src="@drawable/ic_coach_arrow"
android:visibility="gone"
app:tint="?colorPrimary"
tools:ignore="ContentDescription"/>
</LinearLayout>
</FrameLayout>

View File

@ -136,4 +136,11 @@
android:text="@string/leave_space" />
</LinearLayout>
<Space
android:id="@+id/coach_mark_guide"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="150dp"
android:layout_gravity="bottom|center_horizontal"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -2846,6 +2846,7 @@
<string name="space_leave_prompt_msg_only_you">You are the only person here. If you leave, no one will be able to join in the future, including you.</string>
<string name="space_leave_prompt_msg_private">You won\'t be able to rejoin unless you are re-invited.</string>
<string name="space_leave_prompt_msg_as_admin">You\'re the only admin of this space. Leaving it will mean no one has control over it.</string>
<string name="space_leave_coach_mark_text">Use this form to leave anything at the same time.</string>
<!-- TODO delete -->
<string name="leave_all_rooms_and_spaces" tools:ignore="UnusedResources">Leave all rooms and spaces</string>