Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ class WhiteboardFragment :

viewModel.canUndo.onEach { toolbar.undoButton.isEnabled = it }.launchIn(lifecycleScope)
viewModel.canRedo.onEach { toolbar.redoButton.isEnabled = it }.launchIn(lifecycleScope)

binding.whiteboardToolbar.onToolbarVisibilityChanged = { isShown ->
viewModel.setIsToolbarShown(isShown)
}
}

/**
Expand Down Expand Up @@ -197,6 +201,15 @@ class WhiteboardFragment :
toolbar.setAlignment(alignment)
updateToolbarPosition(alignment)
}.launchIn(lifecycleScope)

viewModel.isToolbarShown
.onEach { isShown ->
if (isShown) {
showToolbar()
} else {
hideToolbar()
}
}.launchIn(lifecycleScope)
}

/**
Expand Down Expand Up @@ -374,6 +387,18 @@ class WhiteboardFragment :
}
}

private fun showToolbar() {
binding.whiteboardToolbar.post {
binding.whiteboardToolbar.show()
}
}

private fun hideToolbar() {
binding.whiteboardToolbar.post {
binding.whiteboardToolbar.hide()
}
}

override fun onMenuItemClick(item: MenuItem): Boolean {
Timber.i("WhiteboardFragment::onMenuItemClick %s", item.title)
when (item.itemId) {
Expand All @@ -382,6 +407,7 @@ class WhiteboardFragment :
item.isChecked = !item.isChecked
viewModel.toggleStylusOnlyMode()
}
R.id.action_hide_toolbar -> viewModel.setIsToolbarShown(false)
R.id.action_align_left -> viewModel.setToolbarAlignment(ToolbarAlignment.LEFT)
R.id.action_align_bottom -> viewModel.setToolbarAlignment(ToolbarAlignment.BOTTOM)
R.id.action_align_right -> viewModel.setToolbarAlignment(ToolbarAlignment.RIGHT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ class WhiteboardRepository(
}
set(value) = sharedPreferences.edit { putString(KEY_TOOLBAR_ALIGNMENT, value.name) }

var isToolbarShown: Boolean
get() = sharedPreferences.getBoolean(KEY_IS_TOOLBAR_SHOWN, true)
set(value) = sharedPreferences.edit { putBoolean(KEY_IS_TOOLBAR_SHOWN, value) }

private fun List<BrushInfo>.toPreferenceString(): String = this.joinToString(",") { "${it.color}|${it.width}" }

private fun String.fromPreferenceString(): List<BrushInfo> =
Expand All @@ -120,6 +124,7 @@ class WhiteboardRepository(
private const val KEY_ERASER_MODE = "eraser_mode"
private const val KEY_STYLUS_ONLY_MODE = "stylus_only_mode"
private const val KEY_TOOLBAR_ALIGNMENT = "toolbar_alignment"
private const val KEY_IS_TOOLBAR_SHOWN = "is_toolbar_shown"
const val DEFAULT_STROKE_WIDTH = 10f
const val DEFAULT_ERASER_WIDTH = 30f

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,30 @@ package com.ichi2.anki.ui.windows.reviewer.whiteboard
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.animation.DecelerateInterpolator
import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.card.MaterialCardView
import com.ichi2.anki.databinding.ViewWhiteboardToolbarBinding
import com.ichi2.anki.utils.ext.setDuration
import com.ichi2.utils.dp
import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds

/**
* Tools configuration bar to be used along [WhiteboardView]
*/
class WhiteboardToolbar : MaterialCardView {
class WhiteboardToolbar : LinearLayout {
private val binding: ViewWhiteboardToolbarBinding
private val brushAdapter: BrushAdapter
private val dragHandler = DragHandler()
private var currentAlignment: ToolbarAlignment = ToolbarAlignment.BOTTOM

constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, com.google.android.material.R.attr.materialCardViewStyle)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
binding = ViewWhiteboardToolbarBinding.inflate(LayoutInflater.from(context), this)

Expand All @@ -57,38 +64,61 @@ class WhiteboardToolbar : MaterialCardView {

var onBrushClick: ((view: View, index: Int) -> Unit)? = null
var onBrushLongClick: ((index: Int) -> Unit)? = null
var onToolbarVisibilityChanged: ((isShown: Boolean) -> Unit)? = null

/**
* Updates the internal layout based on the toolbar alignment.
* Switches the RecyclerView orientation and the main layout orientation.
*/
fun setAlignment(alignment: ToolbarAlignment) {
val isVertical = alignment == ToolbarAlignment.LEFT || alignment == ToolbarAlignment.RIGHT
currentAlignment = alignment

binding.innerControlsLayout.orientation = if (isVertical) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL
// Check if the toolbar is docked to a side edge (Left/Right).
val isSideDocked = alignment == ToolbarAlignment.LEFT || alignment == ToolbarAlignment.RIGHT
binding.innerControlsLayout.orientation = if (isSideDocked) VERTICAL else HORIZONTAL

val layoutManager = binding.brushRecyclerView.layoutManager as? LinearLayoutManager
layoutManager?.orientation = if (isVertical) LinearLayoutManager.VERTICAL else LinearLayoutManager.HORIZONTAL
layoutManager?.orientation = if (isSideDocked) LinearLayoutManager.VERTICAL else LinearLayoutManager.HORIZONTAL

val dp = 1.dp.toPx(context)
val dividerMargin = 4 * dp
val dividerParams = binding.controlsDivider.layoutParams as LinearLayout.LayoutParams
if (isVertical) {
dividerParams.width = LinearLayout.LayoutParams.MATCH_PARENT
val dividerParams = binding.controlsDivider.layoutParams as LayoutParams
if (isSideDocked) {
dividerParams.width = LayoutParams.MATCH_PARENT
dividerParams.height = 1 * dp
dividerParams.setMargins(0, dividerMargin, 0, dividerMargin)
binding.innerControlsLayout.updateLayoutParams<LayoutParams> {
binding.innerControlsLayout.updateLayoutParams<MarginLayoutParams> {
marginEnd = 0
}
} else {
dividerParams.width = 1 * dp
dividerParams.height = LinearLayout.LayoutParams.MATCH_PARENT
dividerParams.height = LayoutParams.MATCH_PARENT
dividerParams.setMargins(dividerMargin, 0, dividerMargin, 0)
// leave some space after the brushes
binding.innerControlsLayout.updateLayoutParams<LayoutParams> {
binding.innerControlsLayout.updateLayoutParams<MarginLayoutParams> {
marginEnd = dividerMargin
}
}

// Configure container structure (Handle + Card placement)
val handleSize = 32.dp.toPx(context)
removeView(binding.touchHandle)
removeView(binding.toolbarCard)

orientation = if (isSideDocked) HORIZONTAL else VERTICAL
val handleParams =
if (isSideDocked) {
LayoutParams(handleSize, LayoutParams.MATCH_PARENT)
} else {
LayoutParams(LayoutParams.MATCH_PARENT, handleSize)
}

if (alignment == ToolbarAlignment.LEFT) {
addView(binding.toolbarCard)
addView(binding.touchHandle, handleParams)
} else {
addView(binding.touchHandle, handleParams)
addView(binding.toolbarCard)
}
}

/**
Expand All @@ -111,4 +141,156 @@ class WhiteboardToolbar : MaterialCardView {
) {
brushAdapter.updateSelection(activeIndex, isEraserActive)
}

/**
* Animates the toolbar to its hidden (peeking) state.
*/
fun hide() = dragHandler.hide()

/**
* Animates the toolbar to its fully visible state.
*/
fun show() = dragHandler.show()

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = dragHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev)

override fun onTouchEvent(event: MotionEvent): Boolean = dragHandler.onTouchEvent(event) || super.onTouchEvent(event)

private inner class DragHandler {
private var dragStartX = 0f
private var dragStartY = 0f
private var initialTranslationX = 0f
private var initialTranslationY = 0f
private var isDragging = false
private val peekSize = 6.dp.toPx(context)
private val handleSize = 32.dp.toPx(context)
private val touchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop }

private val maxTranslation: Float
get() {
val lp = layoutParams as? MarginLayoutParams
return when (currentAlignment) {
ToolbarAlignment.BOTTOM -> height + (lp?.bottomMargin ?: 0) - (peekSize + handleSize)
ToolbarAlignment.LEFT -> width + (lp?.leftMargin ?: 0) - (peekSize + handleSize)
ToolbarAlignment.RIGHT -> width + (lp?.rightMargin ?: 0) - (peekSize + handleSize)
}.toFloat()
}

fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
dragStartX = ev.rawX
dragStartY = ev.rawY
initialTranslationX = translationX
initialTranslationY = translationY
isDragging = false
// Let children handle the initial down event (e.g. clicks)
return false
}
MotionEvent.ACTION_MOVE -> {
if (isDragging) return true
val dx = ev.rawX - dragStartX
val dy = ev.rawY - dragStartY

// Intercept if the swipe is perpendicular to the toolbar main axis
val shouldIntercept =
when (currentAlignment) {
ToolbarAlignment.BOTTOM -> abs(dy) > touchSlop && abs(dy) > abs(dx)
ToolbarAlignment.LEFT, ToolbarAlignment.RIGHT -> abs(dx) > touchSlop && abs(dx) > abs(dy)
}

if (shouldIntercept) {
isDragging = true
return true
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isDragging = false
}
}
return false
}

fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> return true
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - dragStartX
val dy = event.rawY - dragStartY
Comment on lines +218 to +219
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't think it's worth it, but a Point class would allow:

val (dx, dy) = event.toPoint() - dragStart

updateTranslation(dx, dy)
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isDragging = false
snapToNearestState()
return true
}
}
return false
}

private fun updateTranslation(
dx: Float,
dy: Float,
) {
val maxTrans = maxTranslation
when (currentAlignment) {
ToolbarAlignment.BOTTOM -> {
val newTrans = (initialTranslationY + dy).coerceIn(0f, maxTrans)
translationY = newTrans
}
ToolbarAlignment.LEFT -> {
val newTrans = (initialTranslationX + dx).coerceIn(-maxTrans, 0f)
translationX = newTrans
}
ToolbarAlignment.RIGHT -> {
val newTrans = (initialTranslationX + dx).coerceIn(0f, maxTrans)
translationX = newTrans
}
}
}

fun show() {
val animator = animate().setDuration(200.milliseconds).setInterpolator(DecelerateInterpolator())

when (currentAlignment) {
ToolbarAlignment.BOTTOM -> animator.translationY(0f)
ToolbarAlignment.LEFT, ToolbarAlignment.RIGHT -> animator.translationX(0f)
}

animator.start()
onToolbarVisibilityChanged?.invoke(true)
}

fun hide() {
val animator = animate().setDuration(200.milliseconds).setInterpolator(DecelerateInterpolator())
val maxTrans = maxTranslation

when (currentAlignment) {
ToolbarAlignment.BOTTOM -> animator.translationY(maxTrans)
ToolbarAlignment.LEFT -> animator.translationX(-maxTrans)
ToolbarAlignment.RIGHT -> animator.translationX(maxTrans)
}

animator.start()
onToolbarVisibilityChanged?.invoke(false)
}

/**
* Hides the toolbar if nearer the edge of the layout than the toolbar original position,
* which is determined by moving more than >50% of the total distance between the toolbar
* farthest limit and the layout's edge.
*/
private fun snapToNearestState() {
val maxTrans = maxTranslation
val shouldShow =
when (currentAlignment) {
ToolbarAlignment.BOTTOM -> translationY <= maxTrans / 2
ToolbarAlignment.LEFT -> translationX >= -maxTrans / 2
ToolbarAlignment.RIGHT -> translationX <= maxTrans / 2
}

if (shouldShow) show() else hide()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class WhiteboardViewModel(
val eraserMode = MutableStateFlow(EraserMode.INK)
val isStylusOnlyMode = MutableStateFlow(false)
val toolbarAlignment = MutableStateFlow(ToolbarAlignment.BOTTOM)
val isToolbarShown = MutableStateFlow(true)

val eraserDisplayWidth =
combine(eraserMode, inkEraserStrokeWidth, strokeEraserStrokeWidth) { mode, inkWidth, strokeWidth ->
Expand All @@ -120,6 +121,7 @@ class WhiteboardViewModel(
eraserMode.value = repository.eraserMode
isStylusOnlyMode.value = repository.stylusOnlyMode
toolbarAlignment.value = repository.toolbarAlignment
isToolbarShown.value = repository.isToolbarShown

val lastActiveIndex = repository.loadLastActiveBrushIndex(isDarkMode)

Expand Down Expand Up @@ -432,6 +434,16 @@ class WhiteboardViewModel(
}
}

/**
* Sets the toolbar visibility.
*/
fun setIsToolbarShown(isShown: Boolean) {
if (isToolbarShown.value != isShown) {
isToolbarShown.value = isShown
repository.isToolbarShown = isShown
}
}

/**
* Clear the canvas and the undo/redo states
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Brayan Oliveira <69634269+brayandso@users.noreply.github.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.utils.ext

import android.view.ViewPropertyAnimator
import kotlin.time.Duration
import kotlin.time.DurationUnit

fun ViewPropertyAnimator.setDuration(duration: Duration): ViewPropertyAnimator = setDuration(duration.toLong(DurationUnit.MILLISECONDS))
4 changes: 4 additions & 0 deletions AnkiDroid/src/main/res/drawable/ic_hide.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal">
<path android:fillColor="@android:color/white" android:pathData="M644,532L586,474Q595,427 559,386Q523,345 466,354L408,296Q425,288 442.5,284Q460,280 480,280Q555,280 607.5,332.5Q660,385 660,460Q660,480 656,497.5Q652,515 644,532ZM772,658L714,602Q752,573 781.5,538.5Q811,504 832,460Q782,359 688.5,299.5Q595,240 480,240Q451,240 423,244Q395,248 368,256L306,194Q347,177 390,168.5Q433,160 480,160Q631,160 749,243.5Q867,327 920,460Q897,519 859.5,569.5Q822,620 772,658ZM792,904L624,738Q589,749 553.5,754.5Q518,760 480,760Q329,760 211,676.5Q93,593 40,460Q61,407 93,361.5Q125,316 166,280L56,168L112,112L848,848L792,904ZM222,336Q193,362 169,393Q145,424 128,460Q178,561 271.5,620.5Q365,680 480,680Q500,680 519,677.5Q538,675 558,672L522,634Q511,637 501,638.5Q491,640 480,640Q405,640 352.5,587.5Q300,535 300,460Q300,449 301.5,439Q303,429 306,418L222,336ZM541,429L541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429ZM390,504Q390,504 390,504Q390,504 390,504L390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Z"/>
</vector>
Loading
Loading