expo-widgets 58.0.0 → 58.0.2
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/.npmignore +5 -0
- package/CHANGELOG.md +21 -0
- package/android/build.gradle +5 -5
- package/android/src/main/java/expo/modules/widgets/ExpoWidgetEmittableTree.kt +95 -9
- package/android/src/main/java/expo/modules/widgets/ExpoWidgetsPeekWidget.kt +3 -1
- package/android/src/main/java/expo/modules/widgets/WidgetImageLoader.kt +159 -0
- package/bundle/index.ts +44 -4
- package/bundle/jsx-runtime-stub.ts +3 -50
- package/bundle/react-native-stub.ts +22 -2
- package/bundle/react-stub.ts +8 -6
- package/expo-module.config.json +1 -1
- package/ios/Widgets/ChildView.swift +31 -0
- package/ios/Widgets/DynamicView.swift +13 -170
- package/ios/Widgets/ViewRenderer.swift +167 -0
- package/package.json +5 -5
package/.npmignore
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,27 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
+
## 58.0.2 — 2026-09-15
|
|
14
|
+
|
|
15
|
+
### 🎉 New features
|
|
16
|
+
|
|
17
|
+
- [Android] Add support for the `Image` component. ([#50116](https://github.com/expo/expo/pull/50116) by [@jakex7](https://github.com/jakex7))
|
|
18
|
+
|
|
19
|
+
## 58.0.1 — 2026-09-14
|
|
20
|
+
|
|
21
|
+
### 🎉 New features
|
|
22
|
+
|
|
23
|
+
- [iOS] Add support for `Overlay`, `Background`, and `Mask` in widgets and Live Activities. ([#49810](https://github.com/expo/expo/pull/49810) by [@jakex7](https://github.com/jakex7))
|
|
24
|
+
|
|
25
|
+
### 🐛 Bug fixes
|
|
26
|
+
|
|
27
|
+
- [iOS] Preserve SwiftUI view identity across widget and Live Activity updates so system update animations can run. ([#49810](https://github.com/expo/expo/pull/49810) by [@jakex7](https://github.com/jakex7))
|
|
28
|
+
- [Android] Fix Gradle build failure when no Android widget is configured. ([#50038](https://github.com/expo/expo/pull/50038) by [@keith-kurak](https://github.com/keith-kurak))
|
|
29
|
+
|
|
30
|
+
### 💡 Others
|
|
31
|
+
|
|
32
|
+
- Upgrade React Native to 0.88.0-rc.0 ([#49910](https://github.com/expo/expo/pull/49910) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
|
33
|
+
|
|
13
34
|
## 58.0.0 — 2026-09-10
|
|
14
35
|
|
|
15
36
|
### 🛠 Breaking changes
|
package/android/build.gradle
CHANGED
|
@@ -6,7 +6,7 @@ plugins {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
group = 'expo.modules.widgets'
|
|
9
|
-
version = '58.0.
|
|
9
|
+
version = '58.0.2'
|
|
10
10
|
|
|
11
11
|
def expoWidgetsPackageDirFile = project.projectDir.parentFile
|
|
12
12
|
def expoWidgetsPackageDir = expoWidgetsPackageDirFile.absolutePath
|
|
@@ -39,7 +39,7 @@ android {
|
|
|
39
39
|
namespace "expo.modules.widgets"
|
|
40
40
|
defaultConfig {
|
|
41
41
|
versionCode 1
|
|
42
|
-
versionName '58.0.
|
|
42
|
+
versionName '58.0.2'
|
|
43
43
|
|
|
44
44
|
externalNativeBuild {
|
|
45
45
|
cmake {
|
|
@@ -108,7 +108,7 @@ def generateExpoWidgetsLayoutRegistry = tasks.register("generateExpoWidgetsLayou
|
|
|
108
108
|
inputs.file file("$expoWidgetsPackageDir/metro.config.js")
|
|
109
109
|
inputs.file file("$expoWidgetsPackageDir/scripts/build-layout-registry.mjs")
|
|
110
110
|
inputs.file file("$expoWidgetsPackageDir/bundle/layout-registry-entry.js")
|
|
111
|
-
inputs.
|
|
111
|
+
inputs.files(expoWidgetsLayoutRegistryConfigFile)
|
|
112
112
|
outputs.file(expoWidgetsLayoutRegistryOutput)
|
|
113
113
|
|
|
114
114
|
def command = [
|
|
@@ -143,6 +143,6 @@ dependencies {
|
|
|
143
143
|
implementation 'com.facebook.react:react-android'
|
|
144
144
|
implementation 'com.facebook.react:hermes-android'
|
|
145
145
|
implementation expoModule.getExpoDependency("expo-ui")
|
|
146
|
-
api 'io.github.jakex7.peek:peek-emittables:0.
|
|
147
|
-
implementation "androidx.glance:glance:1.2.0
|
|
146
|
+
api 'io.github.jakex7.peek:peek-emittables:0.3.0'
|
|
147
|
+
implementation "androidx.glance:glance:1.2.0"
|
|
148
148
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
@file:SuppressLint("RestrictedApi")
|
|
2
|
+
|
|
1
3
|
package expo.modules.widgets
|
|
2
4
|
|
|
5
|
+
import android.annotation.SuppressLint
|
|
3
6
|
import android.content.Context
|
|
4
7
|
import android.graphics.Color as AndroidColor
|
|
8
|
+
import androidx.compose.ui.unit.DpSize
|
|
5
9
|
import androidx.compose.ui.unit.TextUnit
|
|
6
10
|
import androidx.compose.ui.unit.dp
|
|
7
11
|
import androidx.compose.ui.unit.sp
|
|
@@ -22,6 +26,8 @@ import expo.modules.ui.FillMaxHeightParams
|
|
|
22
26
|
import expo.modules.ui.FillMaxSizeParams
|
|
23
27
|
import expo.modules.ui.FillMaxWidthParams
|
|
24
28
|
import expo.modules.ui.HeightParams
|
|
29
|
+
import expo.modules.ui.graphics.ImageSource
|
|
30
|
+
import expo.modules.ui.image.ImageContentScale
|
|
25
31
|
import expo.modules.ui.LayoutProps
|
|
26
32
|
import expo.modules.ui.LinearProgressIndicatorProps
|
|
27
33
|
import expo.modules.ui.LoadingIndicatorProps
|
|
@@ -54,6 +60,9 @@ import expo.modules.ui.convertibles.VerticalArrangementDefault
|
|
|
54
60
|
import androidx.compose.ui.graphics.Color
|
|
55
61
|
import androidx.glance.Emittable
|
|
56
62
|
import androidx.glance.EmittableButton
|
|
63
|
+
import androidx.glance.EmittableImage
|
|
64
|
+
import androidx.glance.ImageProvider
|
|
65
|
+
import androidx.glance.TintColorFilterParams
|
|
57
66
|
import androidx.glance.GlanceModifier
|
|
58
67
|
import androidx.glance.action.clickable
|
|
59
68
|
import androidx.glance.appwidget.CheckboxDefaults
|
|
@@ -67,6 +76,7 @@ import androidx.glance.appwidget.RadioButtonDefaults
|
|
|
67
76
|
import androidx.glance.appwidget.SwitchDefaults
|
|
68
77
|
import androidx.glance.background
|
|
69
78
|
import androidx.glance.layout.Alignment
|
|
79
|
+
import androidx.glance.layout.ContentScale
|
|
70
80
|
import androidx.glance.layout.EmittableBox
|
|
71
81
|
import androidx.glance.layout.EmittableColumn
|
|
72
82
|
import androidx.glance.layout.EmittableRow
|
|
@@ -80,6 +90,8 @@ import androidx.glance.layout.size
|
|
|
80
90
|
import androidx.glance.layout.width
|
|
81
91
|
import androidx.glance.layout.wrapContentHeight
|
|
82
92
|
import androidx.glance.layout.wrapContentWidth
|
|
93
|
+
import androidx.glance.semantics.contentDescription
|
|
94
|
+
import androidx.glance.semantics.semantics
|
|
83
95
|
import androidx.glance.text.EmittableText
|
|
84
96
|
import androidx.glance.text.FontStyle
|
|
85
97
|
import androidx.glance.text.FontWeight
|
|
@@ -92,6 +104,7 @@ import androidx.glance.appwidget.lazy.EmittableLazyColumn
|
|
|
92
104
|
import androidx.glance.appwidget.lazy.EmittableLazyListItem
|
|
93
105
|
import androidx.glance.appwidget.lazy.ReservedItemIdRangeEnd
|
|
94
106
|
import io.github.jakex7.peek.glance.determinateCircularProgressIndicatorEmittable
|
|
107
|
+
import kotlin.math.roundToInt
|
|
95
108
|
|
|
96
109
|
private val DefaultCheckedColor = ColorProvider(Color(0xff6750a4))
|
|
97
110
|
private val DefaultUncheckedColor = ColorProvider(Color(0xff79747e))
|
|
@@ -99,13 +112,14 @@ private val DefaultCheckedTrackColor = ColorProvider(Color(0xffe8def8))
|
|
|
99
112
|
private val DefaultUncheckedTrackColor = ColorProvider(Color(0xffe7e0ec))
|
|
100
113
|
|
|
101
114
|
private class WidgetConverterContext(
|
|
102
|
-
override val applicationContext: Context
|
|
115
|
+
override val applicationContext: Context,
|
|
116
|
+
val widgetSize: DpSize
|
|
103
117
|
) : ConverterContext {
|
|
104
118
|
override val runtime: Runtime? = null
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
internal fun ReadableMap.toPeekRoot(context: Context, source: String): Emittable {
|
|
108
|
-
val converterContext = WidgetConverterContext(context)
|
|
121
|
+
internal fun ReadableMap.toPeekRoot(context: Context, source: String, size: DpSize): Emittable {
|
|
122
|
+
val converterContext = WidgetConverterContext(context, size)
|
|
109
123
|
return EmittableColumn().also { root ->
|
|
110
124
|
root.children += toPeekNodes(converterContext, source)
|
|
111
125
|
}
|
|
@@ -120,12 +134,13 @@ private fun createErrorText(message: String): EmittableText {
|
|
|
120
134
|
}
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
private fun ReadableMap.toPeekNodes(converterContext:
|
|
137
|
+
private fun ReadableMap.toPeekNodes(converterContext: WidgetConverterContext, source: String): List<Emittable> {
|
|
124
138
|
return when (typeName()) {
|
|
125
139
|
"BoxView" -> listOf(toPeekBox(converterContext, source))
|
|
126
140
|
"CheckboxView" -> listOf(toPeekCheckBox(converterContext))
|
|
127
141
|
"CircularProgressIndicatorView" -> listOf(toPeekCircularProgress(converterContext))
|
|
128
142
|
"ColumnView" -> listOf(toPeekColumn(converterContext, source))
|
|
143
|
+
"ImageView" -> listOf(toPeekImage(converterContext))
|
|
129
144
|
"LazyColumnView" -> listOf(toPeekLazyColumn(converterContext, source))
|
|
130
145
|
"LinearProgressIndicatorView" -> listOf(toPeekLinearProgress(converterContext))
|
|
131
146
|
"LoadingIndicatorView" -> listOf(toPeekLoadingIndicator(converterContext))
|
|
@@ -143,7 +158,7 @@ private fun ReadableMap.toPeekNodes(converterContext: ConverterContext, source:
|
|
|
143
158
|
}
|
|
144
159
|
}
|
|
145
160
|
|
|
146
|
-
private fun ReadableMap.toPeekBox(converterContext:
|
|
161
|
+
private fun ReadableMap.toPeekBox(converterContext: WidgetConverterContext, source: String): EmittableBox {
|
|
147
162
|
val props = props<LayoutProps>(converterContext)
|
|
148
163
|
return EmittableBox().also {
|
|
149
164
|
it.modifier = props.modifiers.toPeekModifier(converterContext)
|
|
@@ -152,7 +167,7 @@ private fun ReadableMap.toPeekBox(converterContext: ConverterContext, source: St
|
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
169
|
|
|
155
|
-
private fun ReadableMap.toPeekRow(converterContext:
|
|
170
|
+
private fun ReadableMap.toPeekRow(converterContext: WidgetConverterContext, source: String): EmittableRow {
|
|
156
171
|
val props = props<LayoutProps>(converterContext)
|
|
157
172
|
return EmittableRow().also {
|
|
158
173
|
it.modifier = props.modifiers.toPeekModifier(converterContext)
|
|
@@ -162,7 +177,7 @@ private fun ReadableMap.toPeekRow(converterContext: ConverterContext, source: St
|
|
|
162
177
|
}
|
|
163
178
|
}
|
|
164
179
|
|
|
165
|
-
private fun ReadableMap.toPeekColumn(converterContext:
|
|
180
|
+
private fun ReadableMap.toPeekColumn(converterContext: WidgetConverterContext, source: String): EmittableColumn {
|
|
166
181
|
val props = props<LayoutProps>(converterContext)
|
|
167
182
|
return EmittableColumn().also {
|
|
168
183
|
it.modifier = props.modifiers.toPeekModifier(converterContext)
|
|
@@ -172,7 +187,7 @@ private fun ReadableMap.toPeekColumn(converterContext: ConverterContext, source:
|
|
|
172
187
|
}
|
|
173
188
|
}
|
|
174
189
|
|
|
175
|
-
private fun ReadableMap.toPeekLazyColumn(converterContext:
|
|
190
|
+
private fun ReadableMap.toPeekLazyColumn(converterContext: WidgetConverterContext, source: String): EmittableLazyColumn {
|
|
176
191
|
val props = props<LayoutProps>(converterContext)
|
|
177
192
|
val alignment = props.toPeekHorizontalAlignment()
|
|
178
193
|
return EmittableLazyColumn().also { lazyColumn ->
|
|
@@ -214,7 +229,68 @@ private fun ReadableMap.toPeekText(converterContext: ConverterContext): Emittabl
|
|
|
214
229
|
}
|
|
215
230
|
}
|
|
216
231
|
|
|
217
|
-
private fun ReadableMap.
|
|
232
|
+
private fun ReadableMap.toPeekImage(converterContext: WidgetConverterContext): Emittable {
|
|
233
|
+
val props = props<WidgetImageProps>(converterContext)
|
|
234
|
+
val source = props.source
|
|
235
|
+
?: return createErrorText("Image has no source. Pass source={{ uri }} with a drawable resource name or a file URI.")
|
|
236
|
+
val provider = runCatching {
|
|
237
|
+
loadWidgetImage(converterContext.applicationContext, source, props.imageTarget(converterContext))
|
|
238
|
+
}.getOrElse { error ->
|
|
239
|
+
return createErrorText(error.message ?: "Image '${source.uri}' could not be loaded.")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return EmittableImage().also {
|
|
243
|
+
it.provider = provider
|
|
244
|
+
it.modifier = props.imageModifier(converterContext)
|
|
245
|
+
it.contentScale = props.contentScale.toGlanceContentScale()
|
|
246
|
+
it.colorFilterParams = props.tint.toGlanceColorProvider()?.let(::TintColorFilterParams)
|
|
247
|
+
it.alpha = props.alpha.coerceIn(0f, 1f)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun WidgetImageProps.imageModifier(converterContext: ConverterContext): GlanceModifier {
|
|
252
|
+
val modifier = modifiers.toPeekModifier(converterContext)
|
|
253
|
+
val description = contentDescription ?: return modifier
|
|
254
|
+
return modifier.semantics { contentDescription = description }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private fun WidgetImageProps.imageTarget(converterContext: WidgetConverterContext): WidgetImageTarget {
|
|
258
|
+
val metrics = converterContext.applicationContext.resources.displayMetrics
|
|
259
|
+
var widthDp = converterContext.widgetSize.width.value.takeIf { it.isFinite() && it > 0f }
|
|
260
|
+
?: (metrics.widthPixels / metrics.density)
|
|
261
|
+
var heightDp = converterContext.widgetSize.height.value.takeIf { it.isFinite() && it > 0f }
|
|
262
|
+
?: (metrics.heightPixels / metrics.density)
|
|
263
|
+
|
|
264
|
+
for (modifier in modifiers) {
|
|
265
|
+
when (modifier["\$type"]?.asString()) {
|
|
266
|
+
"size" -> modifier.asRecord<SizeParams>(converterContext)?.let {
|
|
267
|
+
widthDp = it.width.toFloat()
|
|
268
|
+
heightDp = it.height.toFloat()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
"width" -> modifier.asRecord<WidthParams>(converterContext)?.let { widthDp = it.width.toFloat() }
|
|
272
|
+
"height" -> modifier.asRecord<HeightParams>(converterContext)?.let { heightDp = it.height.toFloat() }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return WidgetImageTarget(
|
|
277
|
+
widthPx = (widthDp * metrics.density).roundToInt().coerceAtLeast(1),
|
|
278
|
+
heightPx = (heightDp * metrics.density).roundToInt().coerceAtLeast(1)
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private fun ImageContentScale.toGlanceContentScale(): ContentScale {
|
|
283
|
+
return when (this) {
|
|
284
|
+
ImageContentScale.FIT -> ContentScale.Fit
|
|
285
|
+
ImageContentScale.CROP -> ContentScale.Crop
|
|
286
|
+
ImageContentScale.FILL_BOUNDS -> ContentScale.FillBounds
|
|
287
|
+
// RemoteViews only offers fit, crop and stretch, so the remaining scales map to the nearest one.
|
|
288
|
+
ImageContentScale.INSIDE, ImageContentScale.NONE -> ContentScale.Fit
|
|
289
|
+
ImageContentScale.FILL_WIDTH, ImageContentScale.FILL_HEIGHT -> ContentScale.Crop
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private fun ReadableMap.toPeekButton(converterContext: WidgetConverterContext, source: String): Emittable {
|
|
218
294
|
val props = props<WidgetButtonProps>(converterContext)
|
|
219
295
|
val children = children()
|
|
220
296
|
val action =
|
|
@@ -603,3 +679,13 @@ internal data class WidgetButtonProps(
|
|
|
603
679
|
val modifiers: ModifierList = emptyList(),
|
|
604
680
|
val target: String? = null
|
|
605
681
|
) : ComposeProps
|
|
682
|
+
|
|
683
|
+
@OptimizedComposeProps
|
|
684
|
+
internal data class WidgetImageProps(
|
|
685
|
+
val source: ImageSource? = null,
|
|
686
|
+
val contentScale: ImageContentScale = ImageContentScale.FIT,
|
|
687
|
+
val contentDescription: String? = null,
|
|
688
|
+
val tint: AndroidColor? = null,
|
|
689
|
+
val alpha: Float = 1f,
|
|
690
|
+
val modifiers: ModifierList = emptyList()
|
|
691
|
+
) : ComposeProps
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
@file:SuppressLint("RestrictedApi")
|
|
1
2
|
package expo.modules.widgets
|
|
2
3
|
|
|
4
|
+
import android.annotation.SuppressLint
|
|
3
5
|
import android.content.Context
|
|
4
6
|
import android.os.Bundle
|
|
5
7
|
import androidx.compose.ui.unit.DpSize
|
|
@@ -26,6 +28,6 @@ internal class ExpoWidgetsPeekWidget(
|
|
|
26
28
|
val environment = getWidgetEnvironment(appContext)
|
|
27
29
|
val node = evaluateLayout(appContext, layout, props, environment)
|
|
28
30
|
|
|
29
|
-
return node.toPeekRoot(appContext, widgetName)
|
|
31
|
+
return node.toPeekRoot(appContext, widgetName, size)
|
|
30
32
|
}
|
|
31
33
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
@file:SuppressLint("RestrictedApi")
|
|
2
|
+
|
|
3
|
+
package expo.modules.widgets
|
|
4
|
+
|
|
5
|
+
import android.annotation.SuppressLint
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.graphics.Bitmap
|
|
8
|
+
import android.graphics.BitmapFactory
|
|
9
|
+
import android.graphics.ImageDecoder
|
|
10
|
+
import android.graphics.Matrix
|
|
11
|
+
import android.media.ExifInterface
|
|
12
|
+
import android.net.Uri
|
|
13
|
+
import android.os.Build
|
|
14
|
+
import androidx.glance.ImageProvider
|
|
15
|
+
import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
|
|
16
|
+
import expo.modules.ui.graphics.ImageSource
|
|
17
|
+
import java.io.FileNotFoundException
|
|
18
|
+
import java.io.InputStream
|
|
19
|
+
import kotlin.math.max
|
|
20
|
+
|
|
21
|
+
internal data class WidgetImageTarget(val widthPx: Int, val heightPx: Int)
|
|
22
|
+
|
|
23
|
+
// RemoteViews rejects a widget update whose bitmaps exceed its memory budget.
|
|
24
|
+
private const val MAX_WIDGET_IMAGE_PIXELS = 1_048_576L
|
|
25
|
+
private const val MAX_WIDGET_IMAGE_DIMENSION_PX = 2048
|
|
26
|
+
|
|
27
|
+
internal fun loadWidgetImage(context: Context, source: ImageSource, target: WidgetImageTarget): ImageProvider {
|
|
28
|
+
val uri = Uri.parse(source.uri)
|
|
29
|
+
return when (uri.scheme) {
|
|
30
|
+
null -> ImageProvider(resolveDrawableId(context, source.uri))
|
|
31
|
+
"file", "content", "android.resource" -> ImageProvider(decodeBitmap(context, uri, target))
|
|
32
|
+
"http", "https" -> throw IllegalArgumentException(
|
|
33
|
+
"Remote images are not supported in widgets ('${source.uri}'). Download the image to widgetsDirectory from the app and pass its file URI."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
else -> throw IllegalArgumentException(
|
|
37
|
+
"Image URI scheme '${uri.scheme}' is not supported in widgets ('${source.uri}'). Pass a drawable resource name or a file URI."
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private fun resolveDrawableId(context: Context, name: String): Int {
|
|
43
|
+
val resourceId = ResourceDrawableIdHelper.getResourceDrawableId(context, name)
|
|
44
|
+
require(resourceId > 0) {
|
|
45
|
+
"Image resource '$name' was not found in the app's drawables. Add it to the app's res/drawable directory or pass a file URI."
|
|
46
|
+
}
|
|
47
|
+
return resourceId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private fun decodeBitmap(context: Context, uri: Uri, target: WidgetImageTarget): Bitmap {
|
|
51
|
+
return runCatching {
|
|
52
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
53
|
+
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) { decoder, info, _ ->
|
|
54
|
+
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
|
55
|
+
decoder.setTargetSampleSize(calculateInSampleSize(info.size.width, info.size.height, target))
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
decodeWithBitmapFactory(context, uri, target)
|
|
59
|
+
}
|
|
60
|
+
}.getOrElse { error ->
|
|
61
|
+
throw IllegalArgumentException(
|
|
62
|
+
if (error is FileNotFoundException) {
|
|
63
|
+
"Image '$uri' does not exist. Copy the image to widgetsDirectory before updating the widget."
|
|
64
|
+
} else {
|
|
65
|
+
"Image '$uri' could not be decoded. Widgets support bitmap formats such as PNG, JPEG and WebP."
|
|
66
|
+
},
|
|
67
|
+
error
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun decodeWithBitmapFactory(context: Context, uri: Uri, target: WidgetImageTarget): Bitmap {
|
|
73
|
+
val open: () -> InputStream = {
|
|
74
|
+
context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException(uri.toString())
|
|
75
|
+
}
|
|
76
|
+
val orientation = open().use { input ->
|
|
77
|
+
runCatching {
|
|
78
|
+
ExifInterface(input).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
|
79
|
+
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
|
80
|
+
}
|
|
81
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
82
|
+
open().use { input -> BitmapFactory.decodeStream(input, null, bounds) }
|
|
83
|
+
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Image '$uri' has no decodable bounds." }
|
|
84
|
+
|
|
85
|
+
val swapsDimensions = orientation.swapsDimensions()
|
|
86
|
+
val options = BitmapFactory.Options().apply {
|
|
87
|
+
inSampleSize = calculateInSampleSize(
|
|
88
|
+
sourceWidth = if (swapsDimensions) {
|
|
89
|
+
bounds.outHeight
|
|
90
|
+
} else {
|
|
91
|
+
bounds.outWidth
|
|
92
|
+
},
|
|
93
|
+
sourceHeight = if (swapsDimensions) {
|
|
94
|
+
bounds.outWidth
|
|
95
|
+
} else {
|
|
96
|
+
bounds.outHeight
|
|
97
|
+
},
|
|
98
|
+
target = target
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
val bitmap = requireNotNull(open().use { input -> BitmapFactory.decodeStream(input, null, options) }) {
|
|
102
|
+
"Image '$uri' could not be decoded."
|
|
103
|
+
}
|
|
104
|
+
return bitmap.applyExifOrientation(orientation)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun calculateInSampleSize(sourceWidth: Int, sourceHeight: Int, target: WidgetImageTarget): Int {
|
|
108
|
+
val targetWidth = target.widthPx.coerceAtLeast(1)
|
|
109
|
+
val targetHeight = target.heightPx.coerceAtLeast(1)
|
|
110
|
+
var sampleSize = 1
|
|
111
|
+
while (sourceWidth / (sampleSize * 2) >= targetWidth && sourceHeight / (sampleSize * 2) >= targetHeight) {
|
|
112
|
+
sampleSize *= 2
|
|
113
|
+
}
|
|
114
|
+
while (
|
|
115
|
+
(sourceWidth.toLong() / sampleSize) * (sourceHeight.toLong() / sampleSize) > MAX_WIDGET_IMAGE_PIXELS ||
|
|
116
|
+
max(sourceWidth, sourceHeight) / sampleSize > MAX_WIDGET_IMAGE_DIMENSION_PX
|
|
117
|
+
) {
|
|
118
|
+
sampleSize *= 2
|
|
119
|
+
}
|
|
120
|
+
return sampleSize
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private fun Bitmap.applyExifOrientation(orientation: Int): Bitmap {
|
|
124
|
+
val matrix = Matrix()
|
|
125
|
+
when (orientation) {
|
|
126
|
+
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
|
|
127
|
+
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
|
|
128
|
+
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
|
129
|
+
matrix.setRotate(180f)
|
|
130
|
+
matrix.postScale(-1f, 1f)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
|
134
|
+
matrix.setRotate(90f)
|
|
135
|
+
matrix.postScale(-1f, 1f)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
|
|
139
|
+
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
|
140
|
+
matrix.setRotate(-90f)
|
|
141
|
+
matrix.postScale(-1f, 1f)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
|
|
145
|
+
else -> return this
|
|
146
|
+
}
|
|
147
|
+
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true).also { oriented ->
|
|
148
|
+
if (oriented !== this) {
|
|
149
|
+
recycle()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private fun Int.swapsDimensions(): Boolean {
|
|
155
|
+
return this == ExifInterface.ORIENTATION_TRANSPOSE ||
|
|
156
|
+
this == ExifInterface.ORIENTATION_ROTATE_90 ||
|
|
157
|
+
this == ExifInterface.ORIENTATION_TRANSVERSE ||
|
|
158
|
+
this == ExifInterface.ORIENTATION_ROTATE_270
|
|
159
|
+
}
|
package/bundle/index.ts
CHANGED
|
@@ -18,6 +18,44 @@ declare global {
|
|
|
18
18
|
var __expoWidgetEnvironment: Dictionary | undefined;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Resolve components at the render boundary so parents can inspect their JSX children.
|
|
23
|
+
* Identity is the native type plus the outermost explicit key, or flattened sibling index.
|
|
24
|
+
* Keys must be unique among flattened siblings; dynamic lists should provide explicit keys.
|
|
25
|
+
*/
|
|
26
|
+
function normalizeWidgetTree(node: unknown, position: string | number = 0): unknown {
|
|
27
|
+
let key: string | null = null;
|
|
28
|
+
while (React.isValidElement(node) && typeof node.type === 'function') {
|
|
29
|
+
key ??= node.key;
|
|
30
|
+
const rendered = node.type(node.props);
|
|
31
|
+
node = Array.isArray(rendered)
|
|
32
|
+
? jsxRuntime.jsx(React.Fragment, { children: rendered })
|
|
33
|
+
: rendered;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (Array.isArray(node)) {
|
|
37
|
+
return node.flat(Infinity).map((child, index) => normalizeWidgetTree(child, index));
|
|
38
|
+
}
|
|
39
|
+
if (React.isValidElement(node)) {
|
|
40
|
+
const props = { ...node.props };
|
|
41
|
+
if ('children' in props) {
|
|
42
|
+
props.children = normalizeWidgetTree(props.children);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
...node,
|
|
46
|
+
props,
|
|
47
|
+
__expoWidgetIdentity: JSON.stringify([node.type, key ?? node.key ?? position]),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (node && typeof node === 'object') {
|
|
51
|
+
// Live Activity layouts return a map of section names to roots.
|
|
52
|
+
return Object.fromEntries(
|
|
53
|
+
Object.entries(node).map(([section, child]) => [section, normalizeWidgetTree(child, section)])
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return node;
|
|
57
|
+
}
|
|
58
|
+
|
|
21
59
|
const __expoWidgetRender = function (props: Dictionary, environment: Dictionary) {
|
|
22
60
|
// `materialColors` backs the native expo-ui module stub and stays out of the layout environment.
|
|
23
61
|
const { timestamp, materialColors, ...rest } = environment;
|
|
@@ -27,7 +65,9 @@ const __expoWidgetRender = function (props: Dictionary, environment: Dictionary)
|
|
|
27
65
|
}
|
|
28
66
|
globalThis.__expoWidgetEnvironment = { ...decoratedEnvironment, materialColors };
|
|
29
67
|
|
|
30
|
-
return decorateInteractiveTargets(
|
|
68
|
+
return decorateInteractiveTargets(
|
|
69
|
+
normalizeWidgetTree(globalThis.__expoWidgetLayout(props, decoratedEnvironment))
|
|
70
|
+
);
|
|
31
71
|
};
|
|
32
72
|
|
|
33
73
|
const __expoWidgetHandlePress = function (
|
|
@@ -62,10 +102,10 @@ const __expoWidgetHandlePress = function (
|
|
|
62
102
|
};
|
|
63
103
|
|
|
64
104
|
Object.assign(globalThis, {
|
|
65
|
-
...uiGlobals,
|
|
66
|
-
...jsxRuntime,
|
|
67
|
-
...React,
|
|
68
105
|
...ReactNative,
|
|
106
|
+
...React,
|
|
107
|
+
...jsxRuntime,
|
|
108
|
+
...uiGlobals,
|
|
69
109
|
React,
|
|
70
110
|
__expoWidgetRender,
|
|
71
111
|
__expoWidgetHandlePress,
|
|
@@ -4,63 +4,16 @@ export type ReactElementNode = {
|
|
|
4
4
|
type: unknown;
|
|
5
5
|
key: string | null;
|
|
6
6
|
props: Record<string, any>;
|
|
7
|
+
__expoWidgetIdentity?: string;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
|
-
function ReactElement(
|
|
10
|
-
type: unknown,
|
|
11
|
-
key: string | null,
|
|
12
|
-
props: Record<string, any>
|
|
13
|
-
): ReactElementNode {
|
|
14
|
-
if (typeof type === 'function') {
|
|
15
|
-
return (type as any)(props);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const element = {
|
|
19
|
-
type,
|
|
20
|
-
key,
|
|
21
|
-
props,
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
return element;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
10
|
function jsxProd(
|
|
28
11
|
type: unknown,
|
|
29
12
|
config: any,
|
|
30
13
|
maybeKey?: string | number | bigint
|
|
31
14
|
): ReactElementNode {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
key = '' + maybeKey;
|
|
35
|
-
}
|
|
36
|
-
if (hasValidKey(config)) {
|
|
37
|
-
key = '' + config.key;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
let props: Record<string, any>;
|
|
41
|
-
if (!('key' in config)) {
|
|
42
|
-
props = config;
|
|
43
|
-
} else {
|
|
44
|
-
props = {};
|
|
45
|
-
for (const propName in config) {
|
|
46
|
-
if (propName !== 'key') {
|
|
47
|
-
props[propName] = config[propName];
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// React flattens nested children arrays (e.g. a `.map()` result mixed with sibling
|
|
53
|
-
// elements) during reconciliation. There is no reconciler here - the element tree is
|
|
54
|
-
// serialized for the native widget parsers, which expect a flat children list.
|
|
55
|
-
if (Array.isArray(props.children)) {
|
|
56
|
-
props.children = props.children.flat(Infinity);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return ReactElement(type, key, props);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function hasValidKey(config: any) {
|
|
63
|
-
return config.key !== undefined;
|
|
15
|
+
const { key = maybeKey, ...props } = config;
|
|
16
|
+
return { type, key: key === undefined ? null : String(key), props };
|
|
64
17
|
}
|
|
65
18
|
|
|
66
19
|
const jsxFileName = 'widget';
|
|
@@ -2,10 +2,30 @@ export const PlatformColor = (...names: string[]) => {
|
|
|
2
2
|
return { semantic: names };
|
|
3
3
|
};
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
type ResolvedImageSource = {
|
|
6
|
+
uri: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
height?: number;
|
|
9
|
+
scale?: number;
|
|
7
10
|
};
|
|
8
11
|
|
|
12
|
+
function resolveAssetSource(source: unknown): ResolvedImageSource | null {
|
|
13
|
+
if (source == null) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (typeof source === 'object' && typeof (source as ResolvedImageSource).uri === 'string') {
|
|
17
|
+
return source as ResolvedImageSource;
|
|
18
|
+
}
|
|
19
|
+
if (typeof source === 'number') {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'Bundled require() image sources are not supported inside widgets. Copy the image to widgetsDirectory and pass source={{ uri }} instead.'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
throw new Error('Widget Image source must be an object containing a uri string.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const Image = { resolveAssetSource };
|
|
28
|
+
|
|
9
29
|
export const useColorScheme = (): 'light' | 'dark' | null => {
|
|
10
30
|
return (globalThis.__expoWidgetEnvironment?.colorScheme as 'light' | 'dark' | undefined) ?? null;
|
|
11
31
|
};
|
package/bundle/react-stub.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import type { ReactElementNode } from './jsx-runtime-stub';
|
|
2
|
+
|
|
1
3
|
export const Fragment = 'react.fragment';
|
|
2
4
|
|
|
3
5
|
export const Children = {
|
|
4
6
|
toArray(children: unknown) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return Array.isArray(children) ? children : [children];
|
|
7
|
+
return [children]
|
|
8
|
+
.flat(Infinity)
|
|
9
|
+
.filter((child) => child !== undefined && child !== null && typeof child !== 'boolean');
|
|
9
10
|
},
|
|
10
11
|
};
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
export const isValidElement = (value: unknown): value is ReactElementNode => {
|
|
14
|
+
return value !== null && typeof value === 'object' && 'type' in value && 'props' in value;
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
// TODO(@jakex7): Make this a proper React reconciler in the future. For now, we just need a stub to allow the widget bundle to compile with @expo/ui/jetpack-compose.
|
package/expo-module.config.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import ExpoModulesCore
|
|
3
|
+
|
|
4
|
+
private final class FallbackIdentity {}
|
|
5
|
+
|
|
6
|
+
struct WidgetsChildView<Content: View>: ExpoSwiftUI.StringIdentityChild {
|
|
7
|
+
let childView: Content
|
|
8
|
+
let stringIdentity: String?
|
|
9
|
+
private let fallbackIdentity: FallbackIdentity?
|
|
10
|
+
|
|
11
|
+
init(childView: Content, stringIdentity: String?) {
|
|
12
|
+
self.childView = childView
|
|
13
|
+
self.stringIdentity = stringIdentity
|
|
14
|
+
fallbackIdentity = stringIdentity == nil ? FallbackIdentity() : nil
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
var id: ObjectIdentifier {
|
|
18
|
+
guard let fallbackIdentity else {
|
|
19
|
+
preconditionFailure("Keyed widget children must use childIdentity")
|
|
20
|
+
}
|
|
21
|
+
return ObjectIdentifier(fallbackIdentity)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var uiView: UIView? {
|
|
25
|
+
nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var body: some View {
|
|
29
|
+
childView
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,183 +1,26 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import ExpoModulesCore
|
|
3
|
-
import ExpoUI
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
let id: UUID
|
|
8
|
-
init(id: UUID) {
|
|
9
|
-
self.id = id
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
extension ObjectIdentifier: @retroactive Encodable {
|
|
13
|
-
public func encode(to encoder: Encoder) throws {
|
|
14
|
-
var container = encoder.singleValueContainer()
|
|
15
|
-
try container.encode(String(describing: self))
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
public struct WidgetsDynamicView: View, ExpoSwiftUI.AnyChild {
|
|
20
|
-
let node: [String: Any]
|
|
21
|
-
let name: String
|
|
22
|
-
let kind: WidgetsKind
|
|
23
|
-
let entryIndex: Int?
|
|
24
|
-
let environmentString: String?
|
|
25
|
-
|
|
26
|
-
let uuid = NodeIdentityWrapper(id: UUID())
|
|
27
|
-
public var id: ObjectIdentifier {
|
|
28
|
-
ObjectIdentifier(uuid)
|
|
29
|
-
}
|
|
4
|
+
public struct WidgetsDynamicView: View {
|
|
5
|
+
private let child: any ExpoSwiftUI.AnyChild
|
|
30
6
|
|
|
31
7
|
public init(name: String, kind: WidgetsKind, node: [String: Any]) {
|
|
32
|
-
self.name
|
|
33
|
-
self.kind = kind
|
|
34
|
-
self.node = node
|
|
35
|
-
self.entryIndex = nil
|
|
36
|
-
self.environmentString = nil
|
|
8
|
+
self.init(name: name, kind: kind, node: node, entryIndex: nil, environmentString: nil)
|
|
37
9
|
}
|
|
38
10
|
|
|
39
11
|
public init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
12
|
+
child = WidgetsViewRenderer(
|
|
13
|
+
name: name,
|
|
14
|
+
kind: kind,
|
|
15
|
+
node: node,
|
|
16
|
+
entryIndex: entryIndex,
|
|
17
|
+
environmentString: environmentString
|
|
18
|
+
).render()
|
|
45
19
|
}
|
|
46
20
|
|
|
47
|
-
@ViewBuilder
|
|
48
21
|
public var body: some View {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// its SwiftUI.Text representation. Avoid applying those modifiers again.
|
|
53
|
-
render(TextView.self, TextViewProps.self, updateProps: updateChildren, wrapInUIBaseView: false)
|
|
54
|
-
case "HStackView":
|
|
55
|
-
render(HStackView.self, HStackViewProps.self, updateProps: updateChildren)
|
|
56
|
-
case "VStackView":
|
|
57
|
-
render(VStackView.self, VStackViewProps.self, updateProps: updateChildren)
|
|
58
|
-
case "ZStackView":
|
|
59
|
-
render(ZStackView.self, ZStackViewProps.self, updateProps: updateChildren)
|
|
60
|
-
case "RectangleView":
|
|
61
|
-
render(RectangleView.self, RectangleViewProps.self)
|
|
62
|
-
case "RoundedRectangleView":
|
|
63
|
-
render(RoundedRectangleView.self, RoundedRectangleViewProps.self)
|
|
64
|
-
case "CapsuleView":
|
|
65
|
-
render(CapsuleView.self, CapsuleViewProps.self)
|
|
66
|
-
case "CircleView":
|
|
67
|
-
render(CircleView.self, CircleViewProps.self)
|
|
68
|
-
case "ImageView":
|
|
69
|
-
render(ImageView.self, ImageViewProps.self)
|
|
70
|
-
case "AccessoryWidgetBackgroundView":
|
|
71
|
-
render(AccessoryWidgetBackgroundView.self, AccessoryWidgetBackgroundProps.self)
|
|
72
|
-
case "DividerView":
|
|
73
|
-
render(DividerView.self, DividerProps.self)
|
|
74
|
-
case "EllipseView":
|
|
75
|
-
render(EllipseView.self, EllipseViewProps.self)
|
|
76
|
-
case "LabelView":
|
|
77
|
-
render(LabelView.self, LabelViewProps.self)
|
|
78
|
-
case "ProgressView":
|
|
79
|
-
render(ProgressView.self, ProgressViewProps.self)
|
|
80
|
-
case "SpacerView":
|
|
81
|
-
render(SpacerView.self, SpacerViewProps.self)
|
|
82
|
-
case "UnevenRoundedRectangleView":
|
|
83
|
-
render(UnevenRoundedRectangleView.self, UnevenRoundedRectangleViewProps.self)
|
|
84
|
-
case "GaugeView":
|
|
85
|
-
render(GaugeView.self, GaugeProps.self)
|
|
86
|
-
case "ChartView":
|
|
87
|
-
render(ChartView.self, ChartProps.self)
|
|
88
|
-
case "Button":
|
|
89
|
-
if #available(iOS 17.0, *) {
|
|
90
|
-
switch kind {
|
|
91
|
-
case .widget:
|
|
92
|
-
render(WidgetButtonView.self, ButtonProps.self) { buttonProps in
|
|
93
|
-
try updateChildren(buttonProps)
|
|
94
|
-
buttonProps.source = name
|
|
95
|
-
buttonProps.entryIndex = entryIndex
|
|
96
|
-
buttonProps.environmentString = environmentString
|
|
97
|
-
}
|
|
98
|
-
case .liveActivity:
|
|
99
|
-
render(LiveActivityButtonView.self, ButtonProps.self) { buttonProps in
|
|
100
|
-
try updateChildren(buttonProps)
|
|
101
|
-
buttonProps.source = name
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
} else {
|
|
105
|
-
render(ExpoUI.Button.self, ExpoUI.ButtonProps.self, updateProps: updateChildren)
|
|
106
|
-
}
|
|
107
|
-
case "react.fragment":
|
|
108
|
-
render(FragmentView.self, FragmentProps.self, updateProps: updateChildren)
|
|
109
|
-
case "LinkView":
|
|
110
|
-
render(LinkView.self, LinkViewProps.self, updateProps: updateChildren)
|
|
111
|
-
#if DEBUG
|
|
112
|
-
case "RedBoxView":
|
|
113
|
-
render(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in
|
|
114
|
-
redBoxProps.source = name
|
|
115
|
-
redBoxProps.kind = kind
|
|
116
|
-
}
|
|
117
|
-
default:
|
|
118
|
-
ZStack {
|
|
119
|
-
Color.red.opacity(0.5)
|
|
120
|
-
Text("Unable to get the view for: \(node["type"] as? String ?? "undefined")")
|
|
121
|
-
}
|
|
122
|
-
#else
|
|
123
|
-
default:
|
|
124
|
-
EmptyView()
|
|
125
|
-
#endif
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// MARK: - Render Method
|
|
130
|
-
|
|
131
|
-
@ViewBuilder
|
|
132
|
-
private func render<P, V>(
|
|
133
|
-
_ viewType: V.Type,
|
|
134
|
-
_ propsType: P.Type,
|
|
135
|
-
updateProps: ((_ initialProps: P) throws -> Void)? = nil,
|
|
136
|
-
wrapInUIBaseView: Bool = true
|
|
137
|
-
) -> some View
|
|
138
|
-
where P: UIBaseViewProps, V: ExpoSwiftUI.View, V.Props == P {
|
|
139
|
-
// immediately invoked closure {}() here because we can't use 'do-catch' inside @ViewBuilder
|
|
140
|
-
{
|
|
141
|
-
do {
|
|
142
|
-
if let rawProps = node["props"] as? [String: Any] {
|
|
143
|
-
let props = try propsType.init(rawProps: rawProps, context: WidgetsContext.shared.context)
|
|
144
|
-
try updateProps?(props)
|
|
145
|
-
// TODO(@jakex7): Prevent unwanted transition when view is updated with new props - we want to have the same view instance recreated with new props instead of creating a new view instance and transitioning to it
|
|
146
|
-
if wrapInUIBaseView {
|
|
147
|
-
return AnyView(UIBaseView<P, V>(props: props).transition(.identity))
|
|
148
|
-
}
|
|
149
|
-
return AnyView(V(props: props).transition(.identity))
|
|
150
|
-
}
|
|
151
|
-
return AnyView(EmptyView())
|
|
152
|
-
} catch {
|
|
153
|
-
return AnyView(EmptyView())
|
|
154
|
-
}
|
|
155
|
-
}()
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// MARK: - Function that sets children as DynamicView
|
|
159
|
-
|
|
160
|
-
private func updateChildren<P>(_ initialProps: P) throws
|
|
161
|
-
where P: UIBaseViewProps {
|
|
162
|
-
if let props = node["props"] as? [String: Any] {
|
|
163
|
-
if let children = props["children"] as? [Any] {
|
|
164
|
-
let validChildren = flattenChildNodes(children)
|
|
165
|
-
initialProps.children = validChildren.map { WidgetsDynamicView(name: name, kind: kind, node: $0, entryIndex: entryIndex, environmentString: environmentString) }
|
|
166
|
-
} else if let child = props["children"] as? [String: Any] {
|
|
167
|
-
initialProps.children = [WidgetsDynamicView(name: name, kind: kind, node: child, entryIndex: entryIndex, environmentString: environmentString)]
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private func flattenChildNodes(_ children: [Any]) -> [[String: Any]] {
|
|
173
|
-
return children.flatMap { child -> [[String: Any]] in
|
|
174
|
-
if let node = child as? [String: Any] {
|
|
175
|
-
return [node]
|
|
176
|
-
}
|
|
177
|
-
if let nested = child as? [Any] {
|
|
178
|
-
return flattenChildNodes(nested)
|
|
179
|
-
}
|
|
180
|
-
return []
|
|
181
|
-
}
|
|
22
|
+
// Roots are not inside Children()'s ForEach, so consume their identity here as well.
|
|
23
|
+
let view: any View = child.childView
|
|
24
|
+
AnyView(view).id(child.childIdentity)
|
|
182
25
|
}
|
|
183
26
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import ExpoModulesCore
|
|
3
|
+
import ExpoUI
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
struct WidgetsViewRenderer {
|
|
7
|
+
let name: String
|
|
8
|
+
let kind: WidgetsKind
|
|
9
|
+
let node: [String: Any]
|
|
10
|
+
let entryIndex: Int?
|
|
11
|
+
let environmentString: String?
|
|
12
|
+
|
|
13
|
+
func render() -> any ExpoSwiftUI.AnyChild {
|
|
14
|
+
switch node["type"] as? String {
|
|
15
|
+
case "TextView":
|
|
16
|
+
// TextView applies common modifiers internally so concatenated text keeps
|
|
17
|
+
// its SwiftUI.Text representation. Avoid applying those modifiers again.
|
|
18
|
+
return render(TextView.self, TextViewProps.self, updateProps: updateChildren)
|
|
19
|
+
case "SlotView":
|
|
20
|
+
return render(SlotView.self, SlotViewProps.self, updateProps: updateChildren)
|
|
21
|
+
case "HStackView":
|
|
22
|
+
return renderWithModifiers(HStackView.self, HStackViewProps.self, updateProps: updateChildren)
|
|
23
|
+
case "VStackView":
|
|
24
|
+
return renderWithModifiers(VStackView.self, VStackViewProps.self, updateProps: updateChildren)
|
|
25
|
+
case "ZStackView":
|
|
26
|
+
return renderWithModifiers(ZStackView.self, ZStackViewProps.self, updateProps: updateChildren)
|
|
27
|
+
case "OverlayView":
|
|
28
|
+
return renderWithModifiers(OverlayView.self, OverlayViewProps.self, updateProps: updateChildren)
|
|
29
|
+
case "BackgroundView":
|
|
30
|
+
return renderWithModifiers(BackgroundView.self, BackgroundViewProps.self, updateProps: updateChildren)
|
|
31
|
+
case "MaskView":
|
|
32
|
+
return renderWithModifiers(MaskView.self, MaskViewProps.self, updateProps: updateChildren)
|
|
33
|
+
case "RectangleView":
|
|
34
|
+
return renderWithModifiers(RectangleView.self, RectangleViewProps.self)
|
|
35
|
+
case "RoundedRectangleView":
|
|
36
|
+
return renderWithModifiers(RoundedRectangleView.self, RoundedRectangleViewProps.self)
|
|
37
|
+
case "CapsuleView":
|
|
38
|
+
return renderWithModifiers(CapsuleView.self, CapsuleViewProps.self)
|
|
39
|
+
case "CircleView":
|
|
40
|
+
return renderWithModifiers(CircleView.self, CircleViewProps.self)
|
|
41
|
+
case "ImageView":
|
|
42
|
+
return renderWithModifiers(ImageView.self, ImageViewProps.self)
|
|
43
|
+
case "AccessoryWidgetBackgroundView":
|
|
44
|
+
return renderWithModifiers(AccessoryWidgetBackgroundView.self, AccessoryWidgetBackgroundProps.self)
|
|
45
|
+
case "DividerView":
|
|
46
|
+
return renderWithModifiers(DividerView.self, DividerProps.self)
|
|
47
|
+
case "EllipseView":
|
|
48
|
+
return renderWithModifiers(EllipseView.self, EllipseViewProps.self)
|
|
49
|
+
case "LabelView":
|
|
50
|
+
return renderWithModifiers(LabelView.self, LabelViewProps.self)
|
|
51
|
+
case "ProgressView":
|
|
52
|
+
return renderWithModifiers(ProgressView.self, ProgressViewProps.self)
|
|
53
|
+
case "SpacerView":
|
|
54
|
+
return renderWithModifiers(SpacerView.self, SpacerViewProps.self)
|
|
55
|
+
case "UnevenRoundedRectangleView":
|
|
56
|
+
return renderWithModifiers(UnevenRoundedRectangleView.self, UnevenRoundedRectangleViewProps.self)
|
|
57
|
+
case "GaugeView":
|
|
58
|
+
return renderWithModifiers(GaugeView.self, GaugeProps.self)
|
|
59
|
+
case "ChartView":
|
|
60
|
+
return renderWithModifiers(ChartView.self, ChartProps.self)
|
|
61
|
+
case "Button":
|
|
62
|
+
if #available(iOS 17.0, *) {
|
|
63
|
+
switch kind {
|
|
64
|
+
case .widget:
|
|
65
|
+
return renderWithModifiers(WidgetButtonView.self, ButtonProps.self) { buttonProps in
|
|
66
|
+
try updateChildren(buttonProps)
|
|
67
|
+
buttonProps.source = name
|
|
68
|
+
buttonProps.entryIndex = entryIndex
|
|
69
|
+
buttonProps.environmentString = environmentString
|
|
70
|
+
}
|
|
71
|
+
case .liveActivity:
|
|
72
|
+
return renderWithModifiers(LiveActivityButtonView.self, ButtonProps.self) { buttonProps in
|
|
73
|
+
try updateChildren(buttonProps)
|
|
74
|
+
buttonProps.source = name
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
return renderWithModifiers(ExpoUI.Button.self, ExpoUI.ButtonProps.self, updateProps: updateChildren)
|
|
79
|
+
}
|
|
80
|
+
case "react.fragment":
|
|
81
|
+
return renderWithModifiers(FragmentView.self, FragmentProps.self, updateProps: updateChildren)
|
|
82
|
+
case "LinkView":
|
|
83
|
+
return renderWithModifiers(LinkView.self, LinkViewProps.self, updateProps: updateChildren)
|
|
84
|
+
#if DEBUG
|
|
85
|
+
case "RedBoxView":
|
|
86
|
+
return renderWithModifiers(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in
|
|
87
|
+
redBoxProps.source = name
|
|
88
|
+
redBoxProps.kind = kind
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return makeChild(
|
|
92
|
+
ZStack {
|
|
93
|
+
Color.red.opacity(0.5)
|
|
94
|
+
Text("Unable to get the view for: \(node["type"] as? String ?? "undefined")")
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
#else
|
|
98
|
+
default:
|
|
99
|
+
return makeChild(EmptyView())
|
|
100
|
+
#endif
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// MARK: - Render Method
|
|
105
|
+
|
|
106
|
+
private func renderWithModifiers<P, V>(
|
|
107
|
+
_ viewType: V.Type,
|
|
108
|
+
_ propsType: P.Type,
|
|
109
|
+
updateProps: ((_ initialProps: P) throws -> Void)? = nil
|
|
110
|
+
) -> any ExpoSwiftUI.AnyChild
|
|
111
|
+
where P: UIBaseViewProps, V: ExpoSwiftUI.View, V.Props == P {
|
|
112
|
+
render(UIBaseView<P, V>.self, propsType, updateProps: updateProps)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private func render<P, V>(
|
|
116
|
+
_ viewType: V.Type,
|
|
117
|
+
_ propsType: P.Type,
|
|
118
|
+
updateProps: ((_ initialProps: P) throws -> Void)? = nil
|
|
119
|
+
) -> any ExpoSwiftUI.AnyChild
|
|
120
|
+
where P: ExpoSwiftUI.ViewProps, V: ExpoSwiftUI.View, V.Props == P {
|
|
121
|
+
do {
|
|
122
|
+
guard let rawProps = node["props"] as? [String: Any] else {
|
|
123
|
+
return makeChild(EmptyView())
|
|
124
|
+
}
|
|
125
|
+
let props = try propsType.init(rawProps: rawProps, context: WidgetsContext.shared.context)
|
|
126
|
+
try updateProps?(props)
|
|
127
|
+
return makeChild(V(props: props))
|
|
128
|
+
} catch {
|
|
129
|
+
return makeChild(EmptyView())
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// MARK: - Child Views
|
|
134
|
+
|
|
135
|
+
private func updateChildren<P>(_ initialProps: P) throws
|
|
136
|
+
where P: ExpoSwiftUI.ViewProps {
|
|
137
|
+
if let props = node["props"] as? [String: Any] {
|
|
138
|
+
if let children = props["children"] as? [Any] {
|
|
139
|
+
let validChildren = flattenChildNodes(children)
|
|
140
|
+
initialProps.children = validChildren.map {
|
|
141
|
+
WidgetsViewRenderer(name: name, kind: kind, node: $0, entryIndex: entryIndex, environmentString: environmentString).render()
|
|
142
|
+
}
|
|
143
|
+
} else if let child = props["children"] as? [String: Any] {
|
|
144
|
+
initialProps.children = [
|
|
145
|
+
WidgetsViewRenderer(name: name, kind: kind, node: child, entryIndex: entryIndex, environmentString: environmentString).render()
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private func makeChild<Content: View>(_ view: Content) -> any ExpoSwiftUI.AnyChild {
|
|
152
|
+
// Preserve the native view type for ExpoUI's slot and text inspection.
|
|
153
|
+
WidgetsChildView(childView: view, stringIdentity: node["__expoWidgetIdentity"] as? String)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private func flattenChildNodes(_ children: [Any]) -> [[String: Any]] {
|
|
157
|
+
return children.flatMap { child -> [[String: Any]] in
|
|
158
|
+
if let node = child as? [String: Any] {
|
|
159
|
+
return [node]
|
|
160
|
+
}
|
|
161
|
+
if let nested = child as? [Any] {
|
|
162
|
+
return flattenChildNodes(nested)
|
|
163
|
+
}
|
|
164
|
+
return []
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-widgets",
|
|
3
|
-
"version": "58.0.
|
|
3
|
+
"version": "58.0.2",
|
|
4
4
|
"description": "Widgets.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"expo",
|
|
@@ -28,16 +28,16 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@expo/plist": "^0.9.0",
|
|
31
|
-
"@expo/ui": "~58.0.
|
|
31
|
+
"@expo/ui": "~58.0.2"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@expo/spawn-async": "^1.8.0",
|
|
35
35
|
"@types/jest": "^29.2.1",
|
|
36
36
|
"@types/node": "^22.14.0",
|
|
37
37
|
"@types/react": "~19.2.0",
|
|
38
|
-
"expo": "58.0.0-preview.
|
|
38
|
+
"expo": "58.0.0-preview.2",
|
|
39
39
|
"expo-module-scripts": "56.0.3",
|
|
40
|
-
"react-native": "0.
|
|
40
|
+
"react-native": "0.88.0-rc.0",
|
|
41
41
|
"resolve-workspace-root": "^2.0.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"react": "*",
|
|
46
46
|
"react-native": "*"
|
|
47
47
|
},
|
|
48
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "313e67f5c9dfb16f9deca74a271cd1d4f33a6f82",
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "pnpm run build:lib && pnpm run build:plugin",
|
|
51
51
|
"build:lib": "expo-build src",
|