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 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._
@@ -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.9'
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.9'
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
- onRefreshPress = {
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, VRUtilities.isQuest())
92
+ by preferences(sharedPreferences, true)
94
93
  }
@@ -126,7 +126,7 @@ fun ToolsSection(
126
126
  },
127
127
  content = {
128
128
  NewText(
129
- text = "Show dev menu button"
129
+ text = "Tools button"
130
130
  )
131
131
  },
132
132
  rightComponent = {
@@ -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 android.annotation.SuppressLint
4
- import androidx.compose.animation.core.Animatable
5
- import androidx.compose.animation.core.Spring
6
- import androidx.compose.animation.core.spring
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.clickable
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.rememberCoroutineScope
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.clip
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 expo.modules.devmenu.compose.newtheme.NewAppTheme
33
- import kotlinx.coroutines.launch
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
- interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
40
- onRefreshPress: () -> Unit = {},
41
- onEllipsisPress: () -> Unit = {}
44
+ isPressed: Boolean = false,
45
+ isDragging: Boolean = false,
46
+ isIdle: Boolean = false
42
47
  ) {
43
- val pillShape = RoundedCornerShape(percent = 50)
44
- val horizontalPadding = 14.dp
45
- val verticalPadding = 16.dp
46
- val animatedRotation = remember { Animatable(0f) }
47
- val animatedScale = remember { Animatable(1f) }
48
- val scope = rememberCoroutineScope()
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
- BoxWithConstraints(
65
+ LaunchedEffect(Unit) {
66
+ delay(10_000)
67
+ showLabel = false
68
+ }
69
+
70
+ Column(
71
+ horizontalAlignment = Alignment.CenterHorizontally,
51
72
  modifier = modifier
52
- .shadow(6.dp, pillShape)
53
- .border(
54
- width = 1.dp,
55
- color = NewAppTheme.colors.border.default,
56
- shape = pillShape
57
- )
58
- .background(
59
- color = NewAppTheme.colors.background.default,
60
- shape = pillShape
61
- )
62
- .clip(pillShape)
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
- val iconSize = maxWidth - (horizontalPadding * 2)
65
-
66
- Column(
67
- verticalArrangement = Arrangement.SpaceBetween,
68
- horizontalAlignment = Alignment.CenterHorizontally,
69
- modifier = modifier
70
- .border(
71
- width = 1.dp,
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
- .fillMaxSize()
76
- .padding(horizontal = horizontalPadding, vertical = verticalPadding)
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.refresh_round_icon),
80
- contentDescription = "Refresh",
81
- tint = NewAppTheme.colors.icon.tertiary,
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
- Icon(
106
- painter = painterResource(R.drawable.ellipsis_horizontal),
107
- contentDescription = "Open Dev Menu",
108
- tint = NewAppTheme.colors.icon.tertiary,
110
+ AnimatedVisibility(
111
+ visible = showLabel,
112
+ exit = fadeOut() + scaleOut()
113
+ ) {
114
+ Box(
109
115
  modifier = Modifier
110
- .size(iconSize)
111
- .scale(animatedScale.value)
112
- .clickable(
113
- interactionSource = interactionSource,
114
- // TODO @behenate: Figure out how to use ripple instead of scale animation with an interaction source shared by both buttons
115
- indication = null,
116
- onClick = {
117
- onEllipsisPress()
118
- scope.launch {
119
- animatedScale.snapTo(0.9f)
120
- animatedScale.animateTo(
121
- targetValue = 1f,
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 VerticalActionPillPreview() {
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(modifier = Modifier.size(46.dp, 92.dp))
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(48.dp, 92.dp)
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
- onRefreshPress: () -> Unit = {},
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
- val defaultOffset = bounds.copy(y = bounds.y * 0.75f)
74
- val animatedOffset = remember { Animatable(defaultOffset, Offset.VectorConverter) }
75
- val pillInteractionSource = remember { MutableInteractionSource() }
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
- launch {
136
- pillInteractionSource.emitRelease(firstDown.position)
137
- }
161
+ onPress()
138
162
  } else {
139
- handleRelease(animatedOffset, velocityTracker, totalFabSizePx, bounds)
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
- interactionSource = pillInteractionSource,
148
- onRefreshPress = onRefreshPress,
149
- onEllipsisPress = onOpenMenuPress,
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
- animatedOffset: AnimatableOffset,
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
- let safeArea = geometry.safeAreaInsets
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.9",
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.8",
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": "39a7a009e215eb71a112f4a20dba2d471ab21108"
50
+ "gitHead": "2d0f6652717a0c88f4fb82b7b288bfcbfe0f2373"
51
51
  }