|
| 1 | +/* |
| 2 | + * NeoRegex. |
| 3 | + * |
| 4 | + * Copyright (C) 2024 Irineu A. Silva. |
| 5 | + * |
| 6 | + * This program is free software: you can redistribute it and/or modify |
| 7 | + * it under the terms of the GNU General Public License as published by |
| 8 | + * the Free Software Foundation, either version 3 of the License. |
| 9 | + * |
| 10 | + * This program is distributed in the hope that it will be useful, |
| 11 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 | + * GNU General Public License for more details. |
| 14 | + * |
| 15 | + * You should have received a copy of the GNU General Public License |
| 16 | + * along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 17 | + */ |
| 18 | + |
| 19 | +package com.neoutils.neoregex.core.sharedui.component |
| 20 | + |
| 21 | +import androidx.compose.animation.AnimatedVisibility |
| 22 | +import androidx.compose.animation.core.Animatable |
| 23 | +import androidx.compose.animation.core.VectorConverter |
| 24 | +import androidx.compose.animation.fadeIn |
| 25 | +import androidx.compose.animation.fadeOut |
| 26 | +import androidx.compose.foundation.background |
| 27 | +import androidx.compose.foundation.border |
| 28 | +import androidx.compose.foundation.gestures.detectDragGestures |
| 29 | +import androidx.compose.foundation.hoverable |
| 30 | +import androidx.compose.foundation.indication |
| 31 | +import androidx.compose.foundation.interaction.MutableInteractionSource |
| 32 | +import androidx.compose.foundation.layout.* |
| 33 | +import androidx.compose.foundation.shape.RoundedCornerShape |
| 34 | +import androidx.compose.material3.MaterialTheme.colorScheme |
| 35 | +import androidx.compose.material3.MaterialTheme.typography |
| 36 | +import androidx.compose.material3.Text |
| 37 | +import androidx.compose.material3.ripple |
| 38 | +import androidx.compose.runtime.* |
| 39 | +import androidx.compose.ui.Alignment |
| 40 | +import androidx.compose.ui.Modifier |
| 41 | +import androidx.compose.ui.geometry.Offset |
| 42 | +import androidx.compose.ui.geometry.Rect |
| 43 | +import androidx.compose.ui.input.pointer.pointerInput |
| 44 | +import androidx.compose.ui.layout.boundsInParent |
| 45 | +import androidx.compose.ui.layout.onGloballyPositioned |
| 46 | +import androidx.compose.ui.platform.LocalDensity |
| 47 | +import androidx.compose.ui.unit.dp |
| 48 | +import androidx.compose.ui.unit.round |
| 49 | +import com.neoutils.neoregex.core.common.platform.Platform |
| 50 | +import com.neoutils.neoregex.core.common.platform.platform |
| 51 | +import com.neoutils.neoregex.core.designsystem.theme.NeoTheme.dimensions |
| 52 | +import com.neoutils.neoregex.core.designsystem.theme.NeoTheme.fontSizes |
| 53 | +import com.neoutils.neoregex.core.resources.Res |
| 54 | +import com.neoutils.neoregex.core.resources.match_result_infos |
| 55 | +import kotlinx.coroutines.launch |
| 56 | +import org.jetbrains.compose.resources.pluralStringResource |
| 57 | +import kotlin.time.Duration |
| 58 | +import kotlin.time.DurationUnit |
| 59 | + |
| 60 | +@Composable |
| 61 | +fun BoxWithConstraintsScope.MatchesInfos(infos: MatchesInfos) { |
| 62 | + |
| 63 | + val density = LocalDensity.current |
| 64 | + |
| 65 | + var isRunning by remember { mutableStateOf(false) } |
| 66 | + |
| 67 | + val animateOffset = remember { Animatable(Offset.Zero, Offset.VectorConverter) } |
| 68 | + |
| 69 | + var alignments by remember { mutableStateOf<Map<Alignment, Rect>>(mapOf()) } |
| 70 | + |
| 71 | + var current by remember { mutableStateOf(Alignment.BottomEnd) } |
| 72 | + |
| 73 | + // It needs to be a state to update the reference in pointerInput() |
| 74 | + val halfHeight by rememberUpdatedState(density.run { maxHeight.toPx() / 2f }) |
| 75 | + |
| 76 | + var targetRect by remember { mutableStateOf(Rect.Zero) } |
| 77 | + |
| 78 | + var destination by remember { mutableStateOf(current) } |
| 79 | + |
| 80 | + val scope = rememberCoroutineScope() |
| 81 | + |
| 82 | + listOf( |
| 83 | + Alignment.TopEnd, |
| 84 | + Alignment.BottomEnd |
| 85 | + ).forEach { alignment -> |
| 86 | + AlignmentTarget( |
| 87 | + alignment = alignment, |
| 88 | + isVisible = isRunning, |
| 89 | + isTarget = alignment == destination, |
| 90 | + modifier = Modifier |
| 91 | + .padding(dimensions.tiny) |
| 92 | + .size(density.run { targetRect.size.toDpSize() }) |
| 93 | + .onGloballyPositioned { |
| 94 | + alignments = alignments + mapOf( |
| 95 | + alignment to it.boundsInParent() |
| 96 | + ) |
| 97 | + } |
| 98 | + ) |
| 99 | + } |
| 100 | + |
| 101 | + Text( |
| 102 | + text = pluralStringResource( |
| 103 | + Res.plurals.match_result_infos, |
| 104 | + infos.matches, |
| 105 | + infos.matches, |
| 106 | + infos.duration.toString( |
| 107 | + unit = DurationUnit.MILLISECONDS, |
| 108 | + decimals = 3 |
| 109 | + ) |
| 110 | + ), |
| 111 | + fontSize = fontSizes.tiny, |
| 112 | + style = typography.labelSmall, |
| 113 | + modifier = Modifier |
| 114 | + .align(current) |
| 115 | + .offset { animateOffset.value.round() } |
| 116 | + .padding(dimensions.tiny) // external |
| 117 | + .background( |
| 118 | + color = colorScheme.surfaceVariant, |
| 119 | + shape = RoundedCornerShape(dimensions.tiny) |
| 120 | + ) |
| 121 | + .onGloballyPositioned { |
| 122 | + targetRect = it.boundsInParent() |
| 123 | + } |
| 124 | + .run { |
| 125 | + val hover = remember { MutableInteractionSource() } |
| 126 | + |
| 127 | + hoverable(hover) |
| 128 | + .indication( |
| 129 | + interactionSource = hover, |
| 130 | + indication = ripple() |
| 131 | + ) |
| 132 | + } |
| 133 | + .pointerInput(Unit) { |
| 134 | + detectDragGestures( |
| 135 | + onDragStart = { |
| 136 | + isRunning = true |
| 137 | + }, |
| 138 | + onDragEnd = { |
| 139 | + scope.launch { |
| 140 | + |
| 141 | + alignments[destination]?.let { |
| 142 | + animateOffset.snapTo( |
| 143 | + targetValue = targetRect.topLeft - it.topLeft |
| 144 | + ) |
| 145 | + } |
| 146 | + |
| 147 | + current = destination |
| 148 | + |
| 149 | + animateOffset.animateTo(Offset.Zero) |
| 150 | + } |
| 151 | + |
| 152 | + isRunning = false |
| 153 | + }, |
| 154 | + onDrag = { changes, dragAmount -> |
| 155 | + changes.consume() |
| 156 | + |
| 157 | + scope.launch { |
| 158 | + animateOffset.snapTo( |
| 159 | + targetValue = animateOffset.value + dragAmount |
| 160 | + ) |
| 161 | + } |
| 162 | + |
| 163 | + destination = when { |
| 164 | + current == Alignment.TopEnd && |
| 165 | + targetRect.center.y > halfHeight -> Alignment.BottomEnd |
| 166 | + |
| 167 | + current == Alignment.BottomEnd && |
| 168 | + targetRect.center.y < halfHeight -> Alignment.TopEnd |
| 169 | + |
| 170 | + else -> current |
| 171 | + } |
| 172 | + }, |
| 173 | + onDragCancel = { |
| 174 | + scope.launch { |
| 175 | + animateOffset.animateTo(Offset.Zero) |
| 176 | + } |
| 177 | + |
| 178 | + isRunning = false |
| 179 | + } |
| 180 | + ) |
| 181 | + } |
| 182 | + .padding(dimensions.micro) // internal |
| 183 | + ) |
| 184 | +} |
| 185 | + |
| 186 | +@Composable |
| 187 | +private fun BoxScope.AlignmentTarget( |
| 188 | + isVisible: Boolean, |
| 189 | + alignment: Alignment, |
| 190 | + isTarget: Boolean, |
| 191 | + modifier: Modifier = Modifier |
| 192 | +) = AnimatedVisibility( |
| 193 | + visible = isVisible, |
| 194 | + enter = fadeIn(), |
| 195 | + exit = fadeOut(), |
| 196 | + modifier = modifier.align(alignment) |
| 197 | +) { |
| 198 | + Box( |
| 199 | + modifier = Modifier |
| 200 | + .background( |
| 201 | + color = colorScheme.primary.copy( |
| 202 | + alpha = 0.2f |
| 203 | + ), |
| 204 | + shape = RoundedCornerShape(dimensions.tiny) |
| 205 | + ).run { |
| 206 | + if (isTarget) { |
| 207 | + border( |
| 208 | + width = 1.dp, |
| 209 | + color = colorScheme.primary, |
| 210 | + shape = RoundedCornerShape(dimensions.tiny) |
| 211 | + ) |
| 212 | + } else this |
| 213 | + } |
| 214 | + ) |
| 215 | +} |
| 216 | + |
| 217 | +data class MatchesInfos( |
| 218 | + val duration: Duration, |
| 219 | + val matches: Int |
| 220 | +) { |
| 221 | + companion object { |
| 222 | + fun create( |
| 223 | + duration: Duration = Duration.ZERO, |
| 224 | + matches: Int = 0 |
| 225 | + ): MatchesInfos? = when (platform) { |
| 226 | + is Platform.Android, |
| 227 | + is Platform.Desktop -> { |
| 228 | + MatchesInfos( |
| 229 | + duration = duration, |
| 230 | + matches = matches |
| 231 | + ) |
| 232 | + } |
| 233 | + |
| 234 | + Platform.Web -> null |
| 235 | + } |
| 236 | + } |
| 237 | +} |
0 commit comments