added coach mark view
This commit is contained in:
parent
9a756f2b7a
commit
3dc0ef75ba
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,4 +26,5 @@ sealed class SpaceLeaveAdvanceViewAction : VectorViewModelAction {
|
|||
object ClearError : SpaceLeaveAdvanceViewAction()
|
||||
object SelectAll : SpaceLeaveAdvanceViewAction()
|
||||
object SelectNone : SpaceLeaveAdvanceViewAction()
|
||||
object CoachMarkDismissed : SpaceLeaveAdvanceViewAction()
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue