expo-dev-menu 55.0.8 → 55.0.10
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/DevMenuManager.swift +4 -4
- package/ios/DevMenuWindow-default.swift +1 -2
- package/ios/DevMenuWindow-macOS.swift +1 -1
- package/ios/FAB/DevMenuFABView.swift +64 -49
- package/ios/FAB/DevMenuFABWindow.swift +38 -32
- package/ios/Modules/DevMenuPreferences.swift +1 -1
- package/ios/SwiftUI/DevMenuAppInfo.swift +1 -4
- package/ios/SwiftUI/DevMenuButtons.swift +2 -0
- package/ios/SwiftUI/DevMenuDeveloperTools.swift +2 -3
- 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.10 — 2026-02-26
|
|
14
|
+
|
|
15
|
+
_This version does not introduce any user-facing changes._
|
|
16
|
+
|
|
17
|
+
## 55.0.9 — 2026-02-25
|
|
18
|
+
|
|
19
|
+
_This version does not introduce any user-facing changes._
|
|
20
|
+
|
|
13
21
|
## 55.0.8 — 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.10'
|
|
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.10'
|
|
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>
|
package/ios/DevMenuManager.swift
CHANGED
|
@@ -547,9 +547,9 @@ open class DevMenuManager: NSObject {
|
|
|
547
547
|
fabWindow = DevMenuFABWindow(manager: self, windowScene: windowScene)
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
-
public func updateFABVisibility(
|
|
550
|
+
public func updateFABVisibility() {
|
|
551
551
|
DispatchQueue.main.async { [weak self] in
|
|
552
|
-
guard let self
|
|
552
|
+
guard let self else { return }
|
|
553
553
|
|
|
554
554
|
if self.fabWindow == nil {
|
|
555
555
|
if let windowScene = UIApplication.shared.connectedScenes
|
|
@@ -559,7 +559,7 @@ open class DevMenuManager: NSObject {
|
|
|
559
559
|
}
|
|
560
560
|
|
|
561
561
|
let shouldShow = DevMenuPreferences.showFloatingActionButton
|
|
562
|
-
&&
|
|
562
|
+
&& !self.isVisible
|
|
563
563
|
&& self.currentBridge != nil
|
|
564
564
|
&& !self.isNavigatingHome
|
|
565
565
|
&& DevMenuPreferences.isOnboardingFinished
|
|
@@ -567,7 +567,7 @@ open class DevMenuManager: NSObject {
|
|
|
567
567
|
}
|
|
568
568
|
}
|
|
569
569
|
#else
|
|
570
|
-
public func updateFABVisibility(
|
|
570
|
+
public func updateFABVisibility() {
|
|
571
571
|
// FAB not available on macOS/tvOS
|
|
572
572
|
}
|
|
573
573
|
#endif
|
|
@@ -103,12 +103,11 @@ class DevMenuWindow: UIWindow, PresentationControllerDelegate {
|
|
|
103
103
|
self.backgroundColor = .clear
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
DevMenuManager.shared.updateFABVisibility(menuDismissing: true)
|
|
107
|
-
|
|
108
106
|
devMenuViewController.dismiss(animated: true) {
|
|
109
107
|
self.isDismissing = false
|
|
110
108
|
self.isHidden = true
|
|
111
109
|
self.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
|
110
|
+
DevMenuManager.shared.updateFABVisibility()
|
|
112
111
|
completion?()
|
|
113
112
|
}
|
|
114
113
|
}
|
|
@@ -100,7 +100,7 @@ class DevMenuWindow: NSObject, AnyObject {
|
|
|
100
100
|
ctx.duration = 0.3
|
|
101
101
|
overlay.animator().alphaValue = 0.0
|
|
102
102
|
}, completionHandler: { [weak self] in
|
|
103
|
-
guard let self
|
|
103
|
+
guard let self else { return }
|
|
104
104
|
overlay.removeFromSuperview()
|
|
105
105
|
self.overlayView = nil
|
|
106
106
|
self.hostingView = nil
|
|
@@ -6,8 +6,8 @@ import SwiftUI
|
|
|
6
6
|
|
|
7
7
|
enum FABConstants {
|
|
8
8
|
static let iconSize: CGFloat = 44
|
|
9
|
-
static let margin: CGFloat =
|
|
10
|
-
static let verticalPadding: CGFloat =
|
|
9
|
+
static let margin: CGFloat = 10
|
|
10
|
+
static let verticalPadding: CGFloat = 0
|
|
11
11
|
static let dragThreshold: CGFloat = 10
|
|
12
12
|
static let momentumFactor: CGFloat = 0.35
|
|
13
13
|
static let labelDismissDelay: TimeInterval = 10
|
|
@@ -52,7 +52,7 @@ struct FabPill: View {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
if showLabel {
|
|
55
|
-
Text("
|
|
55
|
+
Text("Tools")
|
|
56
56
|
.font(.system(size: 11, weight: .medium))
|
|
57
57
|
.foregroundStyle(.secondary)
|
|
58
58
|
.fixedSize()
|
|
@@ -77,15 +77,6 @@ struct FabPill: View {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
@ViewBuilder
|
|
81
|
-
private var actionButton: some View {
|
|
82
|
-
if #available(iOS 26.0, *) {
|
|
83
|
-
liquidGlassButton
|
|
84
|
-
} else {
|
|
85
|
-
classicButton
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
80
|
private func startIdleTimer() {
|
|
90
81
|
idleTask?.cancel()
|
|
91
82
|
idleTask = Task {
|
|
@@ -99,22 +90,7 @@ struct FabPill: View {
|
|
|
99
90
|
}
|
|
100
91
|
}
|
|
101
92
|
|
|
102
|
-
|
|
103
|
-
private var liquidGlassButton: some View {
|
|
104
|
-
Image(systemName: "gearshape.fill")
|
|
105
|
-
.resizable()
|
|
106
|
-
.frame(width: FABConstants.imageSize, height: FABConstants.imageSize)
|
|
107
|
-
.foregroundStyle(.white)
|
|
108
|
-
.frame(width: FABConstants.iconSize, height: FABConstants.iconSize)
|
|
109
|
-
.background(
|
|
110
|
-
Circle()
|
|
111
|
-
.frame(width: FABConstants.iconSize + 10, height: FABConstants.iconSize + 10)
|
|
112
|
-
.glassEffect(.clear, in: Circle())
|
|
113
|
-
)
|
|
114
|
-
.shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 4)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private var classicButton: some View {
|
|
93
|
+
private var actionButton: some View {
|
|
118
94
|
Image(systemName: "gearshape.fill")
|
|
119
95
|
.resizable()
|
|
120
96
|
.frame(width: FABConstants.imageSize, height: FABConstants.imageSize)
|
|
@@ -136,15 +112,41 @@ struct DevMenuFABView: View {
|
|
|
136
112
|
|
|
137
113
|
private let fabSize = CGSize(width: 72, height: FABConstants.iconSize + 50)
|
|
138
114
|
|
|
115
|
+
// UserDefaults keys for persisting position
|
|
116
|
+
private static let positionXKey = "DevMenuFAB.positionX"
|
|
117
|
+
private static let positionYKey = "DevMenuFAB.positionY"
|
|
118
|
+
private static let hasStoredPositionKey = "DevMenuFAB.hasStoredPosition"
|
|
119
|
+
|
|
120
|
+
private static func loadStoredPosition() -> CGPoint? {
|
|
121
|
+
guard UserDefaults.standard.bool(forKey: hasStoredPositionKey) else { return nil }
|
|
122
|
+
let x = UserDefaults.standard.double(forKey: positionXKey)
|
|
123
|
+
let y = UserDefaults.standard.double(forKey: positionYKey)
|
|
124
|
+
return CGPoint(x: x, y: y)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private static func savePosition(_ position: CGPoint) {
|
|
128
|
+
UserDefaults.standard.set(position.x, forKey: positionXKey)
|
|
129
|
+
UserDefaults.standard.set(position.y, forKey: positionYKey)
|
|
130
|
+
UserDefaults.standard.set(true, forKey: hasStoredPositionKey)
|
|
131
|
+
}
|
|
132
|
+
|
|
139
133
|
@State private var position: CGPoint = .zero
|
|
140
|
-
@State private var targetPosition: CGPoint = .zero
|
|
141
134
|
@State private var isDragging = false
|
|
142
135
|
@State private var isPressed = false
|
|
143
136
|
@State private var dragStartPosition: CGPoint = .zero
|
|
144
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
|
+
|
|
145
147
|
private let dragSpring: Animation = .spring(
|
|
146
|
-
response: 0.
|
|
147
|
-
dampingFraction: 0.
|
|
148
|
+
response: 0.25,
|
|
149
|
+
dampingFraction: 0.85,
|
|
148
150
|
blendDuration: 0
|
|
149
151
|
)
|
|
150
152
|
|
|
@@ -154,14 +156,34 @@ struct DevMenuFABView: View {
|
|
|
154
156
|
|
|
155
157
|
var body: some View {
|
|
156
158
|
GeometryReader { geometry in
|
|
157
|
-
|
|
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
|
+
)
|
|
158
166
|
|
|
159
167
|
FabPill(isPressed: $isPressed, isDragging: $isDragging)
|
|
160
168
|
.frame(width: fabSize.width, height: fabSize.height)
|
|
161
169
|
.position(x: currentFrame.midX, y: currentFrame.midY)
|
|
162
170
|
.gesture(dragGesture(bounds: geometry.size, safeArea: safeArea))
|
|
163
171
|
.onAppear {
|
|
164
|
-
let initialPos
|
|
172
|
+
let initialPos: CGPoint
|
|
173
|
+
if let storedPos = Self.loadStoredPosition() {
|
|
174
|
+
let margin = FABConstants.margin
|
|
175
|
+
let minX = margin / 2
|
|
176
|
+
let maxX = geometry.size.width - fabSize.width - margin / 2
|
|
177
|
+
let minY = safeArea.top + FABConstants.verticalPadding
|
|
178
|
+
let maxY = geometry.size.height - fabSize.height - safeArea.bottom - FABConstants.verticalPadding
|
|
179
|
+
|
|
180
|
+
initialPos = CGPoint(
|
|
181
|
+
x: storedPos.x.clamped(to: minX...maxX),
|
|
182
|
+
y: storedPos.y.clamped(to: minY...maxY)
|
|
183
|
+
)
|
|
184
|
+
} else {
|
|
185
|
+
initialPos = defaultPosition(bounds: geometry.size, safeArea: safeArea)
|
|
186
|
+
}
|
|
165
187
|
position = initialPos
|
|
166
188
|
onFrameChange(CGRect(origin: initialPos, size: fabSize))
|
|
167
189
|
}
|
|
@@ -192,19 +214,10 @@ struct DevMenuFABView: View {
|
|
|
192
214
|
dragStartPosition = position
|
|
193
215
|
}
|
|
194
216
|
if isDragging {
|
|
195
|
-
let margin = FABConstants.margin
|
|
196
|
-
let minX = margin
|
|
197
|
-
let maxX = bounds.width - fabSize.width - margin
|
|
198
|
-
let minY = margin + safeArea.top + FABConstants.verticalPadding
|
|
199
|
-
let maxY = bounds.height - fabSize.height - margin - safeArea.bottom - FABConstants.verticalPadding
|
|
200
|
-
|
|
201
217
|
let rawX = dragStartPosition.x + value.translation.width
|
|
202
218
|
let rawY = dragStartPosition.y + value.translation.height
|
|
203
219
|
|
|
204
|
-
position = CGPoint(
|
|
205
|
-
x: rawX.clamped(to: minX...maxX),
|
|
206
|
-
y: rawY.clamped(to: minY...maxY)
|
|
207
|
-
)
|
|
220
|
+
position = CGPoint(x: rawX, y: rawY)
|
|
208
221
|
onFrameChange(currentFrame)
|
|
209
222
|
}
|
|
210
223
|
}
|
|
@@ -231,6 +244,7 @@ struct DevMenuFABView: View {
|
|
|
231
244
|
DispatchQueue.main.async {
|
|
232
245
|
isDragging = false
|
|
233
246
|
position = newPos
|
|
247
|
+
Self.savePosition(newPos)
|
|
234
248
|
onFrameChange(CGRect(origin: newPos, size: fabSize))
|
|
235
249
|
}
|
|
236
250
|
}
|
|
@@ -239,8 +253,8 @@ struct DevMenuFABView: View {
|
|
|
239
253
|
|
|
240
254
|
private func defaultPosition(bounds: CGSize, safeArea: EdgeInsets) -> CGPoint {
|
|
241
255
|
CGPoint(
|
|
242
|
-
x: bounds.width - fabSize.width - FABConstants.margin,
|
|
243
|
-
y:
|
|
256
|
+
x: bounds.width - fabSize.width - FABConstants.margin / 2,
|
|
257
|
+
y: safeArea.top + FABConstants.verticalPadding
|
|
244
258
|
)
|
|
245
259
|
}
|
|
246
260
|
|
|
@@ -251,16 +265,17 @@ struct DevMenuFABView: View {
|
|
|
251
265
|
safeArea: EdgeInsets
|
|
252
266
|
) -> CGPoint {
|
|
253
267
|
let margin = FABConstants.margin
|
|
268
|
+
let edgeMargin = margin / 2 // Closer to screen edge when snapped
|
|
254
269
|
let momentumX = velocity.x * FABConstants.momentumFactor
|
|
255
270
|
let momentumY = velocity.y * FABConstants.momentumFactor
|
|
256
271
|
|
|
257
272
|
let estimatedCenterX = point.x + self.fabSize.width / 2 + momentumX
|
|
258
273
|
let targetX: CGFloat = estimatedCenterX < bounds.width / 2
|
|
259
|
-
?
|
|
260
|
-
: bounds.width - self.fabSize.width -
|
|
274
|
+
? edgeMargin
|
|
275
|
+
: bounds.width - self.fabSize.width - edgeMargin
|
|
261
276
|
|
|
262
|
-
let minY =
|
|
263
|
-
let maxY = bounds.height - self.fabSize.height -
|
|
277
|
+
let minY = safeArea.top + FABConstants.verticalPadding
|
|
278
|
+
let maxY = bounds.height - self.fabSize.height - safeArea.bottom - FABConstants.verticalPadding
|
|
264
279
|
let targetY = (point.y + momentumY).clamped(to: minY...maxY)
|
|
265
280
|
|
|
266
281
|
return CGPoint(x: targetX, y: targetY)
|
|
@@ -10,6 +10,8 @@ class DevMenuFABWindow: UIWindow {
|
|
|
10
10
|
private weak var manager: DevMenuManager?
|
|
11
11
|
private var hostingController: UIHostingController<DevMenuFABView>?
|
|
12
12
|
var fabFrame: CGRect = .zero
|
|
13
|
+
private var currentAnimator: UIViewPropertyAnimator?
|
|
14
|
+
private var targetVisibility: Bool?
|
|
13
15
|
|
|
14
16
|
init(manager: DevMenuManager, windowScene: UIWindowScene) {
|
|
15
17
|
self.manager = manager
|
|
@@ -51,44 +53,48 @@ class DevMenuFABWindow: UIWindow {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
func setVisible(_ visible: Bool, animated: Bool = true) {
|
|
56
|
+
// Skip if already animating to the same state
|
|
57
|
+
if targetVisibility == visible {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Cancel any in-progress animation and reset to clean state
|
|
62
|
+
if currentAnimator != nil {
|
|
63
|
+
currentAnimator?.stopAnimation(true)
|
|
64
|
+
currentAnimator = nil
|
|
65
|
+
transform = .identity
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
targetVisibility = visible
|
|
69
|
+
|
|
54
70
|
if visible {
|
|
55
71
|
isHidden = false
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
usingSpringWithDamping: 0.6,
|
|
63
|
-
initialSpringVelocity: 0.8,
|
|
64
|
-
options: .curveEaseOut
|
|
65
|
-
) {
|
|
66
|
-
self.alpha = 1
|
|
67
|
-
self.transform = .identity
|
|
68
|
-
}
|
|
69
|
-
} else {
|
|
70
|
-
alpha = 1
|
|
71
|
-
transform = .identity
|
|
72
|
+
alpha = 0
|
|
73
|
+
transform = edgeTranslation
|
|
74
|
+
|
|
75
|
+
let animator = UIViewPropertyAnimator(duration: animated ? 0.5 : 0, dampingRatio: 0.6) {
|
|
76
|
+
self.alpha = 1
|
|
77
|
+
self.transform = .identity
|
|
72
78
|
}
|
|
79
|
+
animator.addCompletion { [weak self] _ in
|
|
80
|
+
self?.targetVisibility = nil
|
|
81
|
+
}
|
|
82
|
+
currentAnimator = animator
|
|
83
|
+
animator.startAnimation()
|
|
73
84
|
} else {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
self
|
|
83
|
-
self.transform = self.edgeTranslation
|
|
84
|
-
} completion: { _ in
|
|
85
|
-
self.isHidden = true
|
|
86
|
-
self.transform = .identity
|
|
85
|
+
let animator = UIViewPropertyAnimator(duration: animated ? 0.3 : 0, dampingRatio: 0.8) {
|
|
86
|
+
self.alpha = 0
|
|
87
|
+
self.transform = self.edgeTranslation
|
|
88
|
+
}
|
|
89
|
+
animator.addCompletion { [weak self] position in
|
|
90
|
+
self?.targetVisibility = nil
|
|
91
|
+
if position == .end {
|
|
92
|
+
self?.isHidden = true
|
|
93
|
+
self?.transform = .identity
|
|
87
94
|
}
|
|
88
|
-
} else {
|
|
89
|
-
alpha = 0
|
|
90
|
-
isHidden = true
|
|
91
95
|
}
|
|
96
|
+
currentAnimator = animator
|
|
97
|
+
animator.startAnimation()
|
|
92
98
|
}
|
|
93
99
|
}
|
|
94
100
|
|
|
@@ -12,8 +12,6 @@ struct DevMenuAppInfo: View {
|
|
|
12
12
|
.foregroundColor(.primary.opacity(0.6))
|
|
13
13
|
|
|
14
14
|
VStack(spacing: 0) {
|
|
15
|
-
Divider()
|
|
16
|
-
|
|
17
15
|
InfoRow(title: "Version", value: viewModel.appInfo?.appVersion ?? "Unknown")
|
|
18
16
|
|
|
19
17
|
if let runtimeVersion = viewModel.appInfo?.runtimeVersion {
|
|
@@ -44,8 +42,7 @@ struct DevMenuAppInfo: View {
|
|
|
44
42
|
}
|
|
45
43
|
}
|
|
46
44
|
.padding(.horizontal)
|
|
47
|
-
.background(Color.
|
|
48
|
-
.cornerRadius(18)
|
|
45
|
+
.background(Color.expoSecondarySystemBackground, in: RoundedRectangle(cornerRadius: 18))
|
|
49
46
|
}
|
|
50
47
|
}
|
|
51
48
|
}
|
|
@@ -79,15 +79,14 @@ struct DevMenuDeveloperTools: View {
|
|
|
79
79
|
Divider()
|
|
80
80
|
|
|
81
81
|
DevMenuToggleButton(
|
|
82
|
-
title: "
|
|
82
|
+
title: "Tools button",
|
|
83
83
|
icon: "hand.tap",
|
|
84
84
|
isEnabled: viewModel.showFloatingActionButton,
|
|
85
85
|
action: viewModel.toggleFloatingActionButton
|
|
86
86
|
)
|
|
87
87
|
#endif
|
|
88
88
|
}
|
|
89
|
-
.background(Color.
|
|
90
|
-
.cornerRadius(18)
|
|
89
|
+
.background(Color.expoSecondarySystemBackground, in: RoundedRectangle(cornerRadius: 18))
|
|
91
90
|
}
|
|
92
91
|
}
|
|
93
92
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-dev-menu",
|
|
3
|
-
"version": "55.0.
|
|
3
|
+
"version": "55.0.10",
|
|
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.9",
|
|
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": "413b0450434d3e456eb391eca792ee9ac1e1efec"
|
|
51
51
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|