expo-dev-menu 55.0.9 → 55.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/android/build.gradle +2 -2
- package/android/src/debug/java/expo/modules/devmenu/DevMenuFragment.kt +1 -4
- package/android/src/debug/java/expo/modules/devmenu/DevMenuPreferences.kt +1 -2
- package/android/src/debug/java/expo/modules/devmenu/compose/ui/ToolsSection.kt +1 -1
- package/android/src/debug/java/expo/modules/devmenu/fab/FabState.kt +109 -0
- package/android/src/debug/java/expo/modules/devmenu/fab/FabUtils.kt +3 -9
- package/android/src/debug/java/expo/modules/devmenu/fab/FloatingActionButtonContent.kt +97 -104
- package/android/src/debug/java/expo/modules/devmenu/fab/MovableFloatingActionButton.kt +60 -36
- package/android/src/debug/res/drawable/gear_fill.xml +9 -0
- package/ios/FAB/DevMenuFABView.swift +16 -1
- package/package.json +3 -3
- package/android/src/debug/res/drawable-mdpi/ellipsis_horizontal.png +0 -0
- package/android/src/debug/res/drawable-mdpi/refresh_round_icon.png +0 -0
- package/android/src/debug/res/drawable-xhdpi/ellipsis_horizontal.png +0 -0
- package/android/src/debug/res/drawable-xhdpi/refresh_round_icon.png +0 -0
- package/android/src/debug/res/drawable-xxhdpi/ellipsis_horizontal.png +0 -0
- package/android/src/debug/res/drawable-xxhdpi/refresh_round_icon.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
+
## 55.0.11 — 2026-03-04
|
|
14
|
+
|
|
15
|
+
_This version does not introduce any user-facing changes._
|
|
16
|
+
|
|
17
|
+
## 55.0.10 — 2026-02-26
|
|
18
|
+
|
|
19
|
+
_This version does not introduce any user-facing changes._
|
|
20
|
+
|
|
13
21
|
## 55.0.9 — 2026-02-25
|
|
14
22
|
|
|
15
23
|
_This version does not introduce any user-facing changes._
|
package/android/build.gradle
CHANGED
|
@@ -12,7 +12,7 @@ apply plugin: 'expo-module-gradle-plugin'
|
|
|
12
12
|
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
|
13
13
|
|
|
14
14
|
group = 'host.exp.exponent'
|
|
15
|
-
version = '55.0.
|
|
15
|
+
version = '55.0.11'
|
|
16
16
|
|
|
17
17
|
def hasDevLauncher = findProject(":expo-dev-launcher") != null
|
|
18
18
|
def configureInRelease = findProperty("expo.devmenu.configureInRelease") == "true"
|
|
@@ -29,7 +29,7 @@ android {
|
|
|
29
29
|
|
|
30
30
|
defaultConfig {
|
|
31
31
|
versionCode 10
|
|
32
|
-
versionName '55.0.
|
|
32
|
+
versionName '55.0.11'
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
buildTypes {
|
|
@@ -180,10 +180,7 @@ class DevMenuFragment(
|
|
|
180
180
|
DevMenuBottomSheet(viewModel.state, viewModel::onAction)
|
|
181
181
|
MovableFloatingActionButton(
|
|
182
182
|
state = viewModel.state,
|
|
183
|
-
|
|
184
|
-
viewModel.onAction(DevMenuAction.Reload)
|
|
185
|
-
},
|
|
186
|
-
onOpenMenuPress = {
|
|
183
|
+
onPress = {
|
|
187
184
|
viewModel.onAction(DevMenuAction.Open)
|
|
188
185
|
}
|
|
189
186
|
)
|
|
@@ -3,7 +3,6 @@ package expo.modules.devmenu
|
|
|
3
3
|
import android.app.Application
|
|
4
4
|
import android.content.Context.MODE_PRIVATE
|
|
5
5
|
import android.content.SharedPreferences
|
|
6
|
-
import expo.modules.core.utilities.VRUtilities
|
|
7
6
|
import expo.modules.devmenu.helpers.preferences
|
|
8
7
|
|
|
9
8
|
private const val DEV_SETTINGS_PREFERENCES = "expo.modules.devmenu.sharedpreferences"
|
|
@@ -90,5 +89,5 @@ class DevMenuDefaultPreferences(
|
|
|
90
89
|
by preferences(sharedPreferences, false)
|
|
91
90
|
|
|
92
91
|
override var showFab: Boolean
|
|
93
|
-
by preferences(sharedPreferences,
|
|
92
|
+
by preferences(sharedPreferences, true)
|
|
94
93
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package expo.modules.devmenu.fab
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import androidx.compose.animation.core.Animatable
|
|
6
|
+
import androidx.compose.animation.core.VectorConverter
|
|
7
|
+
import androidx.compose.foundation.layout.WindowInsets
|
|
8
|
+
import androidx.compose.foundation.layout.systemBars
|
|
9
|
+
import androidx.compose.runtime.Composable
|
|
10
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
11
|
+
import androidx.compose.runtime.getValue
|
|
12
|
+
import androidx.compose.runtime.mutableLongStateOf
|
|
13
|
+
import androidx.compose.runtime.mutableStateOf
|
|
14
|
+
import androidx.compose.runtime.remember
|
|
15
|
+
import androidx.compose.runtime.setValue
|
|
16
|
+
import androidx.compose.ui.geometry.Offset
|
|
17
|
+
import androidx.compose.ui.platform.LocalContext
|
|
18
|
+
import androidx.compose.ui.platform.LocalDensity
|
|
19
|
+
import androidx.core.content.edit
|
|
20
|
+
import kotlinx.coroutines.delay
|
|
21
|
+
|
|
22
|
+
private const val IdleTimeoutMs = 5_000L
|
|
23
|
+
private const val FAB_PREFS = "expo.modules.devmenu.sharedpreferences"
|
|
24
|
+
private const val FAB_POSITION_X = "fabPositionX"
|
|
25
|
+
private const val FAB_POSITION_Y = "fabPositionY"
|
|
26
|
+
private const val FAB_POSITION_UNSET = -1f
|
|
27
|
+
|
|
28
|
+
data class FabBounds(
|
|
29
|
+
val screen: Offset,
|
|
30
|
+
val safe: Offset,
|
|
31
|
+
val safeMinY: Float,
|
|
32
|
+
val drag: Offset,
|
|
33
|
+
val halfFab: Offset
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
class FabState(
|
|
37
|
+
initialOffset: Offset,
|
|
38
|
+
val bounds: FabBounds,
|
|
39
|
+
val prefs: SharedPreferences
|
|
40
|
+
) {
|
|
41
|
+
val animatedOffset = Animatable(initialOffset, Offset.VectorConverter)
|
|
42
|
+
var isPressed by mutableStateOf(false)
|
|
43
|
+
var isDragging by mutableStateOf(false)
|
|
44
|
+
var isIdle by mutableStateOf(false)
|
|
45
|
+
var isOffScreen by mutableStateOf(false)
|
|
46
|
+
var restingOffset by mutableStateOf(initialOffset)
|
|
47
|
+
var lastInteractionTime by mutableLongStateOf(System.currentTimeMillis())
|
|
48
|
+
|
|
49
|
+
fun onInteraction() {
|
|
50
|
+
lastInteractionTime = System.currentTimeMillis()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fun savePosition(offset: Offset) {
|
|
54
|
+
val safeWidth = bounds.safe.x
|
|
55
|
+
val safeHeight = bounds.safe.y - bounds.safeMinY
|
|
56
|
+
|
|
57
|
+
// Store position as 0–1 ratios within the safe area so it survives screen size changes
|
|
58
|
+
val normalizedX = if (safeWidth > 0f) offset.x / safeWidth else 0f
|
|
59
|
+
val normalizedY = if (safeHeight > 0f) (offset.y - bounds.safeMinY) / safeHeight else 0f
|
|
60
|
+
|
|
61
|
+
prefs.edit {
|
|
62
|
+
putFloat(FAB_POSITION_X, normalizedX)
|
|
63
|
+
putFloat(FAB_POSITION_Y, normalizedY)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Composable
|
|
69
|
+
fun rememberFabState(screenBounds: Offset, totalFabSizePx: Offset): FabState {
|
|
70
|
+
val density = LocalDensity.current
|
|
71
|
+
val systemBarInsets = WindowInsets.systemBars
|
|
72
|
+
val safeInsetTop = with(density) { systemBarInsets.getTop(this).toFloat() }
|
|
73
|
+
val safeInsetBottom = with(density) { systemBarInsets.getBottom(this).toFloat() }
|
|
74
|
+
val halfFab = Offset(totalFabSizePx.x / 2f, totalFabSizePx.y / 2f)
|
|
75
|
+
|
|
76
|
+
val fabBounds = FabBounds(
|
|
77
|
+
screen = screenBounds,
|
|
78
|
+
safe = Offset(screenBounds.x, screenBounds.y - safeInsetBottom),
|
|
79
|
+
safeMinY = safeInsetTop,
|
|
80
|
+
drag = Offset(screenBounds.x + halfFab.x, screenBounds.y + halfFab.y),
|
|
81
|
+
halfFab = halfFab
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
val context = LocalContext.current
|
|
85
|
+
val prefs = remember { context.getSharedPreferences(FAB_PREFS, Context.MODE_PRIVATE) }
|
|
86
|
+
|
|
87
|
+
val initialOffset = remember(fabBounds.safe, fabBounds.safeMinY) {
|
|
88
|
+
val savedX = prefs.getFloat(FAB_POSITION_X, FAB_POSITION_UNSET)
|
|
89
|
+
val savedY = prefs.getFloat(FAB_POSITION_Y, FAB_POSITION_UNSET)
|
|
90
|
+
if (savedX != FAB_POSITION_UNSET && savedY != FAB_POSITION_UNSET) {
|
|
91
|
+
Offset(
|
|
92
|
+
x = (savedX * fabBounds.safe.x).coerceIn(0f, fabBounds.safe.x),
|
|
93
|
+
y = (savedY * (fabBounds.safe.y - fabBounds.safeMinY) + fabBounds.safeMinY).coerceIn(fabBounds.safeMinY, fabBounds.safe.y)
|
|
94
|
+
)
|
|
95
|
+
} else {
|
|
96
|
+
Offset(fabBounds.safe.x, fabBounds.safeMinY)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
val state = remember { FabState(initialOffset, fabBounds, prefs) }
|
|
101
|
+
|
|
102
|
+
LaunchedEffect(state.lastInteractionTime) {
|
|
103
|
+
state.isIdle = false
|
|
104
|
+
delay(IdleTimeoutMs)
|
|
105
|
+
state.isIdle = true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return state
|
|
109
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
package expo.modules.devmenu.fab
|
|
2
2
|
|
|
3
|
-
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
4
|
-
import androidx.compose.foundation.interaction.PressInteraction
|
|
5
3
|
import androidx.compose.runtime.Composable
|
|
6
4
|
import androidx.compose.runtime.LaunchedEffect
|
|
7
5
|
import androidx.compose.runtime.mutableStateOf
|
|
@@ -18,7 +16,8 @@ internal fun calculateTargetPosition(
|
|
|
18
16
|
currentPosition: Offset,
|
|
19
17
|
velocity: PointF,
|
|
20
18
|
bounds: Offset,
|
|
21
|
-
totalFabWidth: Float
|
|
19
|
+
totalFabWidth: Float,
|
|
20
|
+
minY: Float = 0f
|
|
22
21
|
): Offset {
|
|
23
22
|
// Simulate the bubble keeping the movement momentum
|
|
24
23
|
// I've found that these values feel good (assume that the bubble keeps the momentum for ~100ms)
|
|
@@ -32,7 +31,7 @@ internal fun calculateTargetPosition(
|
|
|
32
31
|
}
|
|
33
32
|
val targetY = currentPosition.y + momentumOffsetY
|
|
34
33
|
val newOffset = Offset(targetX, targetY)
|
|
35
|
-
.coerceIn(maxX = bounds.x, maxY = bounds.y)
|
|
34
|
+
.coerceIn(minY = minY, maxX = bounds.x, maxY = bounds.y)
|
|
36
35
|
return newOffset
|
|
37
36
|
}
|
|
38
37
|
|
|
@@ -60,8 +59,3 @@ internal fun <T> rememberPrevious(current: T): T? {
|
|
|
60
59
|
|
|
61
60
|
return previousRef.value
|
|
62
61
|
}
|
|
63
|
-
|
|
64
|
-
internal suspend fun MutableInteractionSource.emitRelease(pressPosition: Offset) {
|
|
65
|
-
val pressInteraction = PressInteraction.Press(pressPosition)
|
|
66
|
-
this.emit(PressInteraction.Release(pressInteraction))
|
|
67
|
-
}
|
|
@@ -1,148 +1,141 @@
|
|
|
1
1
|
package expo.modules.devmenu.fab
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import androidx.compose.animation.core.
|
|
5
|
-
import androidx.compose.animation.
|
|
6
|
-
import androidx.compose.animation.
|
|
3
|
+
import androidx.compose.animation.AnimatedVisibility
|
|
4
|
+
import androidx.compose.animation.core.animateFloatAsState
|
|
5
|
+
import androidx.compose.animation.fadeOut
|
|
6
|
+
import androidx.compose.animation.scaleOut
|
|
7
7
|
import androidx.compose.foundation.background
|
|
8
8
|
import androidx.compose.foundation.border
|
|
9
|
-
import androidx.compose.foundation.
|
|
10
|
-
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
11
|
-
import androidx.compose.foundation.layout.Arrangement
|
|
12
|
-
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
9
|
+
import androidx.compose.foundation.layout.Box
|
|
13
10
|
import androidx.compose.foundation.layout.Column
|
|
14
|
-
import androidx.compose.foundation.layout.fillMaxSize
|
|
15
11
|
import androidx.compose.foundation.layout.padding
|
|
16
12
|
import androidx.compose.foundation.layout.size
|
|
13
|
+
import androidx.compose.foundation.shape.CircleShape
|
|
17
14
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
18
15
|
import androidx.compose.runtime.Composable
|
|
16
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
17
|
+
import androidx.compose.runtime.getValue
|
|
18
|
+
import androidx.compose.runtime.mutableStateOf
|
|
19
19
|
import androidx.compose.runtime.remember
|
|
20
|
-
import androidx.compose.runtime.
|
|
20
|
+
import androidx.compose.runtime.setValue
|
|
21
21
|
import androidx.compose.ui.Alignment
|
|
22
22
|
import androidx.compose.ui.Modifier
|
|
23
|
-
import androidx.compose.ui.draw.
|
|
24
|
-
import androidx.compose.ui.draw.rotate
|
|
23
|
+
import androidx.compose.ui.draw.drawWithContent
|
|
25
24
|
import androidx.compose.ui.draw.scale
|
|
26
25
|
import androidx.compose.ui.draw.shadow
|
|
26
|
+
import androidx.compose.ui.graphics.Color
|
|
27
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
28
|
+
import androidx.compose.ui.graphics.nativeCanvas
|
|
27
29
|
import androidx.compose.ui.res.painterResource
|
|
30
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
28
31
|
import androidx.compose.ui.tooling.preview.Preview
|
|
29
32
|
import androidx.compose.ui.unit.dp
|
|
33
|
+
import androidx.compose.ui.unit.sp
|
|
30
34
|
import com.composeunstyled.Icon
|
|
35
|
+
import com.composeunstyled.Text
|
|
31
36
|
import expo.modules.devmenu.R
|
|
32
|
-
import
|
|
33
|
-
|
|
37
|
+
import kotlinx.coroutines.delay
|
|
38
|
+
|
|
39
|
+
private val FabBlue = Color(0xFF007AFF)
|
|
34
40
|
|
|
35
|
-
@SuppressLint("UnusedBoxWithConstraintsScope")
|
|
36
41
|
@Composable
|
|
37
42
|
fun FloatingActionButtonContent(
|
|
38
43
|
modifier: Modifier = Modifier,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
isPressed: Boolean = false,
|
|
45
|
+
isDragging: Boolean = false,
|
|
46
|
+
isIdle: Boolean = false
|
|
42
47
|
) {
|
|
43
|
-
val
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
val
|
|
48
|
+
val scale by animateFloatAsState(
|
|
49
|
+
targetValue = if (isPressed && !isDragging) 0.9f else 1f,
|
|
50
|
+
label = "pressScale"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
val idleAlpha by animateFloatAsState(
|
|
54
|
+
targetValue = if (isIdle) 0.5f else 1f,
|
|
55
|
+
label = "idleAlpha"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
val idleSaturation by animateFloatAsState(
|
|
59
|
+
targetValue = if (isIdle) 0f else 1f,
|
|
60
|
+
label = "idleSaturation"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
var showLabel by remember { mutableStateOf(true) }
|
|
49
64
|
|
|
50
|
-
|
|
65
|
+
LaunchedEffect(Unit) {
|
|
66
|
+
delay(10_000)
|
|
67
|
+
showLabel = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Column(
|
|
71
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
51
72
|
modifier = modifier
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
.scale(scale)
|
|
74
|
+
.graphicsLayer {
|
|
75
|
+
alpha = idleAlpha
|
|
76
|
+
}
|
|
77
|
+
.drawWithContent {
|
|
78
|
+
val colorMatrix = android.graphics.ColorMatrix()
|
|
79
|
+
colorMatrix.setSaturation(idleSaturation)
|
|
80
|
+
val paint = android.graphics.Paint().apply {
|
|
81
|
+
colorFilter = android.graphics.ColorMatrixColorFilter(colorMatrix)
|
|
82
|
+
}
|
|
83
|
+
drawContext.canvas.nativeCanvas.saveLayer(null, paint)
|
|
84
|
+
drawContent()
|
|
85
|
+
drawContext.canvas.nativeCanvas.restore()
|
|
86
|
+
}
|
|
63
87
|
) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
color = NewAppTheme.colors.border.default,
|
|
73
|
-
shape = pillShape
|
|
88
|
+
Box(
|
|
89
|
+
contentAlignment = Alignment.Center,
|
|
90
|
+
modifier = Modifier
|
|
91
|
+
.shadow(
|
|
92
|
+
elevation = 8.dp,
|
|
93
|
+
shape = CircleShape,
|
|
94
|
+
ambientColor = Color.Black.copy(alpha = 0.4f),
|
|
95
|
+
spotColor = Color.Black.copy(alpha = 0.4f)
|
|
74
96
|
)
|
|
75
|
-
.
|
|
76
|
-
.
|
|
97
|
+
.size(52.dp)
|
|
98
|
+
.border(4.dp, FabBlue.copy(alpha = 0.3f), CircleShape)
|
|
99
|
+
.padding(4.dp)
|
|
100
|
+
.background(FabBlue, CircleShape)
|
|
77
101
|
) {
|
|
78
102
|
Icon(
|
|
79
|
-
painter = painterResource(R.drawable.
|
|
80
|
-
contentDescription = "
|
|
81
|
-
tint =
|
|
82
|
-
modifier = Modifier
|
|
83
|
-
.size(iconSize)
|
|
84
|
-
.rotate(animatedRotation.value)
|
|
85
|
-
.clickable(
|
|
86
|
-
interactionSource = interactionSource,
|
|
87
|
-
indication = null,
|
|
88
|
-
onClick = {
|
|
89
|
-
scope.launch {
|
|
90
|
-
animatedRotation.animateTo(
|
|
91
|
-
targetValue = 360f,
|
|
92
|
-
animationSpec = spring(
|
|
93
|
-
dampingRatio = Spring.DampingRatioLowBouncy,
|
|
94
|
-
stiffness = Spring.StiffnessVeryLow,
|
|
95
|
-
visibilityThreshold = 5f
|
|
96
|
-
)
|
|
97
|
-
)
|
|
98
|
-
onRefreshPress()
|
|
99
|
-
animatedRotation.snapTo(0f)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
)
|
|
103
|
+
painter = painterResource(R.drawable.gear_fill),
|
|
104
|
+
contentDescription = "Tools",
|
|
105
|
+
tint = Color.White,
|
|
106
|
+
modifier = Modifier.size(26.dp)
|
|
103
107
|
)
|
|
108
|
+
}
|
|
104
109
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
AnimatedVisibility(
|
|
111
|
+
visible = showLabel,
|
|
112
|
+
exit = fadeOut() + scaleOut()
|
|
113
|
+
) {
|
|
114
|
+
Box(
|
|
109
115
|
modifier = Modifier
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
animationSpec = spring(
|
|
123
|
-
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
124
|
-
stiffness = Spring.StiffnessLow
|
|
125
|
-
)
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
)
|
|
130
|
-
)
|
|
116
|
+
.padding(top = 6.dp)
|
|
117
|
+
.shadow(4.dp, RoundedCornerShape(percent = 50))
|
|
118
|
+
.background(Color.White, RoundedCornerShape(percent = 50))
|
|
119
|
+
.padding(horizontal = 10.dp, vertical = 4.dp)
|
|
120
|
+
) {
|
|
121
|
+
Text(
|
|
122
|
+
text = "Tools",
|
|
123
|
+
color = Color.Black,
|
|
124
|
+
fontSize = 11.sp,
|
|
125
|
+
fontWeight = FontWeight.SemiBold
|
|
126
|
+
)
|
|
127
|
+
}
|
|
131
128
|
}
|
|
132
129
|
}
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
@Preview(showBackground = true)
|
|
136
133
|
@Composable
|
|
137
|
-
fun
|
|
138
|
-
// You would typically wrap this in your app's theme.
|
|
139
|
-
// Using a basic Column for positioning in the preview.
|
|
134
|
+
fun FloatingActionButtonContentPreview() {
|
|
140
135
|
Column(
|
|
141
|
-
modifier = Modifier
|
|
142
|
-
.padding(32.dp)
|
|
143
|
-
.background(NewAppTheme.colors.border.default),
|
|
136
|
+
modifier = Modifier.padding(32.dp),
|
|
144
137
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
145
138
|
) {
|
|
146
|
-
FloatingActionButtonContent(
|
|
139
|
+
FloatingActionButtonContent()
|
|
147
140
|
}
|
|
148
141
|
}
|
|
@@ -2,16 +2,13 @@ package expo.modules.devmenu.fab
|
|
|
2
2
|
|
|
3
3
|
import android.annotation.SuppressLint
|
|
4
4
|
import androidx.compose.animation.AnimatedVisibility
|
|
5
|
-
import androidx.compose.animation.core.Animatable
|
|
6
|
-
import androidx.compose.animation.core.AnimationVector2D
|
|
7
5
|
import androidx.compose.animation.core.Spring
|
|
8
|
-
import androidx.compose.animation.core.VectorConverter
|
|
9
6
|
import androidx.compose.animation.core.spring
|
|
7
|
+
import androidx.compose.animation.core.tween
|
|
10
8
|
import androidx.compose.animation.fadeIn
|
|
11
9
|
import androidx.compose.animation.fadeOut
|
|
12
10
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
13
11
|
import androidx.compose.foundation.gestures.drag
|
|
14
|
-
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
15
12
|
import androidx.compose.foundation.layout.Box
|
|
16
13
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
17
14
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
@@ -34,12 +31,10 @@ import kotlinx.coroutines.CoroutineScope
|
|
|
34
31
|
import kotlinx.coroutines.coroutineScope
|
|
35
32
|
import kotlinx.coroutines.launch
|
|
36
33
|
|
|
37
|
-
private val FabDefaultSize = DpSize(
|
|
34
|
+
private val FabDefaultSize = DpSize(72.dp, 94.dp)
|
|
38
35
|
private val Margin = 16.dp
|
|
39
36
|
private const val ClickDragTolerance = 40f
|
|
40
37
|
|
|
41
|
-
private typealias AnimatableOffset = Animatable<Offset, AnimationVector2D>
|
|
42
|
-
|
|
43
38
|
/**
|
|
44
39
|
* A floating action button that can be dragged across the screen and springs to the
|
|
45
40
|
* nearest horizontal edge when released. A tap triggers the onClick action.
|
|
@@ -51,8 +46,7 @@ fun MovableFloatingActionButton(
|
|
|
51
46
|
modifier: Modifier = Modifier,
|
|
52
47
|
fabSize: DpSize = FabDefaultSize,
|
|
53
48
|
margin: Dp = Margin,
|
|
54
|
-
|
|
55
|
-
onOpenMenuPress: () -> Unit = {}
|
|
49
|
+
onPress: () -> Unit = {}
|
|
56
50
|
) {
|
|
57
51
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
|
58
52
|
val totalFabSize = DpSize(fabSize.width + margin * 2, fabSize.height + margin * 2)
|
|
@@ -63,6 +57,8 @@ fun MovableFloatingActionButton(
|
|
|
63
57
|
x = constraints.maxWidth - totalFabSizePx.x,
|
|
64
58
|
y = constraints.maxHeight - totalFabSizePx.y
|
|
65
59
|
)
|
|
60
|
+
|
|
61
|
+
val fab = rememberFabState(bounds, totalFabSizePx)
|
|
66
62
|
val isFabDisplayable = state.showFab &&
|
|
67
63
|
!state.isInPictureInPictureMode &&
|
|
68
64
|
bounds.x >= 0f &&
|
|
@@ -70,27 +66,48 @@ fun MovableFloatingActionButton(
|
|
|
70
66
|
|
|
71
67
|
val previousBounds = rememberPrevious(bounds)
|
|
72
68
|
val velocityTracker = remember { ExpoVelocityTracker() }
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
|
|
70
|
+
LaunchedEffect(state.isOpen) {
|
|
71
|
+
if (state.isOpen) {
|
|
72
|
+
fab.restingOffset = fab.animatedOffset.value
|
|
73
|
+
val isOnLeftSide = fab.animatedOffset.value.x < fab.bounds.safe.x / 2f
|
|
74
|
+
val offScreenX = if (isOnLeftSide) -totalFabSizePx.x else constraints.maxWidth.toFloat()
|
|
75
|
+
fab.animatedOffset.animateTo(
|
|
76
|
+
targetValue = Offset(offScreenX, fab.animatedOffset.value.y),
|
|
77
|
+
animationSpec = tween(durationMillis = 500)
|
|
78
|
+
)
|
|
79
|
+
fab.isOffScreen = true
|
|
80
|
+
} else if (fab.isOffScreen) {
|
|
81
|
+
fab.animatedOffset.animateTo(
|
|
82
|
+
targetValue = fab.restingOffset,
|
|
83
|
+
animationSpec = spring(
|
|
84
|
+
dampingRatio = 0.6f,
|
|
85
|
+
stiffness = Spring.StiffnessLow
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
fab.isOffScreen = false
|
|
89
|
+
fab.onInteraction()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
76
92
|
|
|
77
93
|
LaunchedEffect(bounds.x, bounds.y, isFabDisplayable) {
|
|
78
94
|
if (!isFabDisplayable) {
|
|
79
95
|
return@LaunchedEffect
|
|
80
96
|
}
|
|
81
97
|
previousBounds?.let {
|
|
82
|
-
val oldX = animatedOffset.value.x
|
|
83
|
-
val oldY = animatedOffset.value.y
|
|
84
|
-
val newX = (oldX / previousBounds.x) * bounds.x
|
|
85
|
-
val newY = (oldY / previousBounds.y) * bounds.y
|
|
98
|
+
val oldX = fab.animatedOffset.value.x
|
|
99
|
+
val oldY = fab.animatedOffset.value.y
|
|
100
|
+
val newX = (oldX / previousBounds.x) * fab.bounds.safe.x
|
|
101
|
+
val newY = (oldY / previousBounds.y) * fab.bounds.safe.y
|
|
86
102
|
val newTarget = calculateTargetPosition(
|
|
87
103
|
currentPosition = Offset(newX, newY),
|
|
88
104
|
velocity = ExpoVelocityTracker.PointF(0f, 0f),
|
|
89
|
-
bounds = bounds,
|
|
90
|
-
totalFabWidth = totalFabSizePx.x
|
|
105
|
+
bounds = fab.bounds.safe,
|
|
106
|
+
totalFabWidth = totalFabSizePx.x,
|
|
107
|
+
minY = fab.bounds.safeMinY
|
|
91
108
|
)
|
|
92
109
|
|
|
93
|
-
animatedOffset.snapTo(newTarget)
|
|
110
|
+
fab.animatedOffset.snapTo(newTarget)
|
|
94
111
|
}
|
|
95
112
|
}
|
|
96
113
|
|
|
@@ -101,7 +118,7 @@ fun MovableFloatingActionButton(
|
|
|
101
118
|
) {
|
|
102
119
|
Box(
|
|
103
120
|
modifier = Modifier
|
|
104
|
-
.offset { animatedOffset.value.toIntOffset() }
|
|
121
|
+
.offset { fab.animatedOffset.value.toIntOffset() }
|
|
105
122
|
.size(totalFabSize)
|
|
106
123
|
.padding(margin)
|
|
107
124
|
.pointerInput(bounds.x, bounds.y) {
|
|
@@ -110,33 +127,40 @@ fun MovableFloatingActionButton(
|
|
|
110
127
|
awaitPointerEventScope {
|
|
111
128
|
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
|
112
129
|
val pointerId = firstDown.id
|
|
130
|
+
fab.isPressed = true
|
|
131
|
+
fab.isDragging = false
|
|
132
|
+
fab.onInteraction()
|
|
113
133
|
|
|
114
134
|
launch {
|
|
115
|
-
animatedOffset.stop()
|
|
135
|
+
fab.animatedOffset.stop()
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
var dragDistance = 0f
|
|
119
|
-
var dragOffset = animatedOffset.value
|
|
139
|
+
var dragOffset = fab.animatedOffset.value
|
|
120
140
|
|
|
121
141
|
drag(pointerId) { change ->
|
|
122
142
|
dragOffset = (dragOffset + change.positionChange())
|
|
123
|
-
.coerceIn(maxX = bounds.x, maxY = bounds.y)
|
|
143
|
+
.coerceIn(minX = -fab.bounds.halfFab.x, maxX = fab.bounds.drag.x, minY = -fab.bounds.halfFab.y, maxY = fab.bounds.drag.y)
|
|
124
144
|
dragDistance += change.positionChange().getDistance()
|
|
125
145
|
velocityTracker.registerPosition(dragOffset.x, dragOffset.y)
|
|
126
146
|
|
|
127
147
|
if (dragDistance > ClickDragTolerance) {
|
|
148
|
+
fab.isDragging = true
|
|
128
149
|
launch {
|
|
129
|
-
animatedOffset.animateTo(dragOffset)
|
|
150
|
+
fab.animatedOffset.animateTo(dragOffset)
|
|
130
151
|
}
|
|
131
152
|
}
|
|
132
153
|
}
|
|
154
|
+
|
|
155
|
+
fab.isPressed = false
|
|
156
|
+
fab.isDragging = false
|
|
157
|
+
fab.onInteraction()
|
|
158
|
+
|
|
133
159
|
if (dragDistance < ClickDragTolerance) {
|
|
134
160
|
velocityTracker.clear()
|
|
135
|
-
|
|
136
|
-
pillInteractionSource.emitRelease(firstDown.position)
|
|
137
|
-
}
|
|
161
|
+
onPress()
|
|
138
162
|
} else {
|
|
139
|
-
handleRelease(
|
|
163
|
+
handleRelease(fab, velocityTracker, totalFabSizePx)
|
|
140
164
|
}
|
|
141
165
|
}
|
|
142
166
|
}
|
|
@@ -144,9 +168,9 @@ fun MovableFloatingActionButton(
|
|
|
144
168
|
}
|
|
145
169
|
) {
|
|
146
170
|
FloatingActionButtonContent(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
171
|
+
isPressed = fab.isPressed,
|
|
172
|
+
isDragging = fab.isDragging,
|
|
173
|
+
isIdle = fab.isIdle,
|
|
150
174
|
modifier = Modifier.fillMaxSize()
|
|
151
175
|
)
|
|
152
176
|
}
|
|
@@ -158,17 +182,16 @@ fun MovableFloatingActionButton(
|
|
|
158
182
|
* Handles the release of the FAB, calculating momentum and animating it to the nearest edge.
|
|
159
183
|
*/
|
|
160
184
|
private fun CoroutineScope.handleRelease(
|
|
161
|
-
|
|
185
|
+
fab: FabState,
|
|
162
186
|
velocityTracker: ExpoVelocityTracker,
|
|
163
|
-
totalFabSizePx: Offset
|
|
164
|
-
bounds: Offset
|
|
187
|
+
totalFabSizePx: Offset
|
|
165
188
|
) {
|
|
166
189
|
val velocity = velocityTracker.calculateVelocity()
|
|
167
|
-
val newOffset = calculateTargetPosition(animatedOffset.value, velocity, bounds, totalFabSizePx.x)
|
|
190
|
+
val newOffset = calculateTargetPosition(fab.animatedOffset.value, velocity, fab.bounds.safe, totalFabSizePx.x, fab.bounds.safeMinY)
|
|
168
191
|
|
|
169
192
|
velocityTracker.clear()
|
|
170
193
|
launch {
|
|
171
|
-
animatedOffset.animateTo(
|
|
194
|
+
fab.animatedOffset.animateTo(
|
|
172
195
|
targetValue = newOffset,
|
|
173
196
|
animationSpec = spring(
|
|
174
197
|
dampingRatio = 0.65f,
|
|
@@ -176,5 +199,6 @@ private fun CoroutineScope.handleRelease(
|
|
|
176
199
|
),
|
|
177
200
|
initialVelocity = Offset(velocity.x, velocity.y)
|
|
178
201
|
)
|
|
202
|
+
fab.savePosition(newOffset)
|
|
179
203
|
}
|
|
180
204
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="24dp"
|
|
3
|
+
android:height="24dp"
|
|
4
|
+
android:viewportWidth="24"
|
|
5
|
+
android:viewportHeight="24">
|
|
6
|
+
<path
|
|
7
|
+
android:fillColor="#FFFFFF"
|
|
8
|
+
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
|
|
9
|
+
</vector>
|
|
@@ -135,6 +135,15 @@ struct DevMenuFABView: View {
|
|
|
135
135
|
@State private var isPressed = false
|
|
136
136
|
@State private var dragStartPosition: CGPoint = .zero
|
|
137
137
|
|
|
138
|
+
// Get safe area from window since .ignoresSafeArea() may zero out geometry values
|
|
139
|
+
private var windowSafeArea: UIEdgeInsets {
|
|
140
|
+
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
141
|
+
let window = windowScene.windows.first else {
|
|
142
|
+
return .zero
|
|
143
|
+
}
|
|
144
|
+
return window.safeAreaInsets
|
|
145
|
+
}
|
|
146
|
+
|
|
138
147
|
private let dragSpring: Animation = .spring(
|
|
139
148
|
response: 0.25,
|
|
140
149
|
dampingFraction: 0.85,
|
|
@@ -147,7 +156,13 @@ struct DevMenuFABView: View {
|
|
|
147
156
|
|
|
148
157
|
var body: some View {
|
|
149
158
|
GeometryReader { geometry in
|
|
150
|
-
|
|
159
|
+
// Use window safe area - geometry values may be incorrect initially or due to .ignoresSafeArea()
|
|
160
|
+
let safeArea = EdgeInsets(
|
|
161
|
+
top: windowSafeArea.top,
|
|
162
|
+
leading: windowSafeArea.left,
|
|
163
|
+
bottom: windowSafeArea.bottom,
|
|
164
|
+
trailing: windowSafeArea.right
|
|
165
|
+
)
|
|
151
166
|
|
|
152
167
|
FabPill(isPressed: $isPressed, isDragging: $isDragging)
|
|
153
168
|
.frame(width: fabSize.width, height: fabSize.height)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-dev-menu",
|
|
3
|
-
"version": "55.0.
|
|
3
|
+
"version": "55.0.11",
|
|
4
4
|
"description": "Expo/React Native module with the developer menu.",
|
|
5
5
|
"main": "build/DevMenu.js",
|
|
6
6
|
"types": "build/DevMenu.d.ts",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@babel/preset-typescript": "^7.7.4",
|
|
40
40
|
"@testing-library/react-native": "^13.3.0",
|
|
41
41
|
"babel-plugin-module-resolver": "^5.0.0",
|
|
42
|
-
"babel-preset-expo": "~55.0.
|
|
42
|
+
"babel-preset-expo": "~55.0.10",
|
|
43
43
|
"expo-module-scripts": "^55.0.2",
|
|
44
44
|
"react": "19.2.0",
|
|
45
45
|
"react-native": "0.83.2"
|
|
@@ -47,5 +47,5 @@
|
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"expo": "*"
|
|
49
49
|
},
|
|
50
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "2d0f6652717a0c88f4fb82b7b288bfcbfe0f2373"
|
|
51
51
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|