@expo/ui 57.0.10 → 57.0.12

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/android/build.gradle +2 -2
  3. package/android/src/main/java/expo/modules/ui/ExpoUIModule.kt +21 -1
  4. package/android/src/main/java/expo/modules/ui/graphics/DrawablePainter.kt +43 -0
  5. package/android/src/main/java/expo/modules/ui/{icon/VectorIconLoader.kt → graphics/ImageLoader.kt} +122 -87
  6. package/android/src/main/java/expo/modules/ui/graphics/ImageSource.kt +28 -0
  7. package/android/src/main/java/expo/modules/ui/{icon → graphics}/ResourceIdHelper.kt +1 -1
  8. package/android/src/main/java/expo/modules/ui/icon/IconView.kt +7 -78
  9. package/android/src/main/java/expo/modules/ui/image/ImageView.kt +136 -0
  10. package/build/State/useWorkletProp.d.ts.map +1 -1
  11. package/build/jetpack-compose/Image/index.d.ts +107 -0
  12. package/build/jetpack-compose/Image/index.d.ts.map +1 -0
  13. package/build/jetpack-compose/index.d.ts +1 -0
  14. package/build/jetpack-compose/index.d.ts.map +1 -1
  15. package/build/swift-ui/Alert/index.d.ts.map +1 -1
  16. package/build/swift-ui/ConfirmationDialog/index.d.ts.map +1 -1
  17. package/build/swift-ui/Overlay/index.d.ts.map +1 -1
  18. package/build/swift-ui/Popover/index.d.ts.map +1 -1
  19. package/build/swift-ui/Slider/index.d.ts.map +1 -1
  20. package/build/swift-ui/modifiers/index.d.ts +61 -9
  21. package/build/swift-ui/modifiers/index.d.ts.map +1 -1
  22. package/build/universal/BottomSheet/index.android.d.ts +1 -1
  23. package/build/universal/BottomSheet/index.android.d.ts.map +1 -1
  24. package/build/universal/BottomSheet/index.d.ts +1 -1
  25. package/build/universal/BottomSheet/index.d.ts.map +1 -1
  26. package/build/universal/BottomSheet/index.ios.d.ts +1 -1
  27. package/build/universal/BottomSheet/index.ios.d.ts.map +1 -1
  28. package/build/universal/BottomSheet/types.d.ts +28 -0
  29. package/build/universal/BottomSheet/types.d.ts.map +1 -1
  30. package/build/universal/BottomSheet/utils.d.ts +13 -0
  31. package/build/universal/BottomSheet/utils.d.ts.map +1 -0
  32. package/expo-module.config.json +1 -1
  33. package/ios/Convertibles/PaddingValue.swift +57 -0
  34. package/ios/Modifiers/MenuIndicatorModifier.swift +18 -0
  35. package/ios/Modifiers/MenuStyleModifier.swift +28 -0
  36. package/ios/Modifiers/ViewModifierRegistry.swift +105 -18
  37. package/ios/SliderView.swift +11 -5
  38. package/ios/State/WorkletCallbackClassDef.swift +7 -0
  39. package/ios/Toggle/ToggleView.swift +16 -18
  40. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{57.0.10/expo.modules.ui-57.0.10-sources.jar → 57.0.12/expo.modules.ui-57.0.12-sources.jar} +0 -0
  41. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12-sources.jar.md5 +1 -0
  42. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12-sources.jar.sha1 +1 -0
  43. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12-sources.jar.sha256 +1 -0
  44. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12-sources.jar.sha512 +1 -0
  45. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.aar +0 -0
  46. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.aar.md5 +1 -0
  47. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.aar.sha1 +1 -0
  48. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.aar.sha256 +1 -0
  49. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.aar.sha512 +1 -0
  50. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{57.0.10/expo.modules.ui-57.0.10.module → 57.0.12/expo.modules.ui-57.0.12.module} +22 -22
  51. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.module.md5 +1 -0
  52. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.module.sha1 +1 -0
  53. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.module.sha256 +1 -0
  54. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.module.sha512 +1 -0
  55. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{57.0.10/expo.modules.ui-57.0.10.pom → 57.0.12/expo.modules.ui-57.0.12.pom} +1 -1
  56. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.pom.md5 +1 -0
  57. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.pom.sha1 +1 -0
  58. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.pom.sha256 +1 -0
  59. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.12/expo.modules.ui-57.0.12.pom.sha512 +1 -0
  60. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml +4 -4
  61. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.md5 +1 -1
  62. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha1 +1 -1
  63. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha256 +1 -1
  64. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha512 +1 -1
  65. package/package.json +4 -4
  66. package/src/State/useWorkletProp.ts +31 -14
  67. package/src/jetpack-compose/Image/index.tsx +149 -0
  68. package/src/jetpack-compose/index.ts +1 -0
  69. package/src/swift-ui/Alert/index.tsx +1 -0
  70. package/src/swift-ui/ConfirmationDialog/index.tsx +1 -0
  71. package/src/swift-ui/Overlay/index.tsx +1 -0
  72. package/src/swift-ui/Popover/index.tsx +1 -0
  73. package/src/swift-ui/Slider/index.tsx +14 -8
  74. package/src/swift-ui/modifiers/index.ts +70 -8
  75. package/src/universal/BottomSheet/index.android.tsx +13 -2
  76. package/src/universal/BottomSheet/index.ios.tsx +9 -1
  77. package/src/universal/BottomSheet/index.tsx +17 -2
  78. package/src/universal/BottomSheet/types.ts +33 -0
  79. package/src/universal/BottomSheet/utils.ts +35 -0
  80. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10-sources.jar.md5 +0 -1
  81. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10-sources.jar.sha1 +0 -1
  82. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10-sources.jar.sha256 +0 -1
  83. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10-sources.jar.sha512 +0 -1
  84. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.aar +0 -0
  85. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.aar.md5 +0 -1
  86. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.aar.sha1 +0 -1
  87. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.aar.sha256 +0 -1
  88. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.aar.sha512 +0 -1
  89. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.module.md5 +0 -1
  90. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.module.sha1 +0 -1
  91. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.module.sha256 +0 -1
  92. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.module.sha512 +0 -1
  93. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.pom.md5 +0 -1
  94. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.pom.sha1 +0 -1
  95. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.pom.sha256 +0 -1
  96. package/local-maven-repo/expo/modules/ui/expo.modules.ui/57.0.10/expo.modules.ui-57.0.10.pom.sha512 +0 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,34 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.12 — 2026-08-20
14
+
15
+ ### 🎉 New features
16
+
17
+ - [iOS] Added `'default'` as a value for every edge of the `padding` modifier, so a single edge can use the system default padding while the other edges use explicit lengths. ([#48903](https://github.com/expo/expo/pull/48903) by [@Den1Marshall](https://github.com/Den1Marshall))
18
+ - [android] Add `shouldDismissOnBackPress` prop to the universal `BottomSheet` component, forwarding it to `ModalBottomSheet` via `properties.shouldDismissOnBackPress`. When false, the back press does not dismiss the sheet (and the press still does not reach BackHandler). ([#48987](https://github.com/expo/expo/pull/48987) by [@webdevsamran](https://github.com/webdevsamran))
19
+ - [universal] Added a `contentPadding` prop to `BottomSheet`, so sheet content can reach the sheet's edges instead of always sitting inside a hardcoded 16-unit inset. ([#48902](https://github.com/expo/expo/issues/48902) by [@kilarsky](https://github.com/kilarsky))
20
+
21
+ ### 🐛 Bug fixes
22
+
23
+ - [Android] Preserve vector drawable `fillType` values when loading images so even-odd paths render correctly. ([#49049](https://github.com/expo/expo/pull/49049) by [@aonan-brex](https://github.com/aonan-brex))
24
+ - [iOS] Fixed `Overlay`, `Alert`, `ConfirmationDialog` and `Popover` silently ignoring the `modifiers` prop, which never reached the native view. ([#48949](https://github.com/expo/expo/pull/48949) by [@Den1Marshall](https://github.com/Den1Marshall))
25
+ - [iOS] Fix `Slider` ignoring `value` prop updates after the first drag. ([#49075](https://github.com/expo/expo/issues/49075) by [@matinzd](https://github.com/matinzd)) ([#49139](https://github.com/expo/expo/pull/49139) by [@nishan](https://github.com/intergalacticspacehighway))
26
+ - [iOS] Fix `Toggle` rendering local state instead of `isOn`, which left `community/menu` checkmarks out of sync and dropped every second `onPressAction`. `Toggle` is now fully controlled: when `isOn` is set, it only moves once JS updates the prop. ([#48982](https://github.com/expo/expo/issues/48982) by [@JustJoostNL](https://github.com/JustJoostNL)) ([#49021](https://github.com/expo/expo/pull/49021) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
27
+
28
+ ## 57.0.11 — 2026-08-14
29
+
30
+ ### 🎉 New features
31
+
32
+ - [iOS] Added `menuStyle` and `menuIndicator` SwiftUI modifiers, which together with `buttonStyle('plain')` keep a `Menu`'s custom label as its entire trigger on Mac Catalyst. ([#48447](https://github.com/expo/expo/issues/48447) by [@cvburgess](https://github.com/cvburgess)) ([#48448](https://github.com/expo/expo/pull/48448) by [@cvburgess](https://github.com/cvburgess))
33
+ - [iOS] Added the `listRowSeparatorTint` modifier, so a list row separator can be recolored and not only shown or hidden. ([#48804](https://github.com/expo/expo/issues/48804) by [@marco242424](https://github.com/marco242424)) ([#48810](https://github.com/expo/expo/pull/48810) by [@expo-bot](https://github.com/expo-bot))
34
+ - [iOS] Added the `geometryGroup` modifier, so a view's subviews can resolve their geometry against it instead of an animating ancestor. Without it, a `Button` inside a container with an `animation` modifier can draw its label away from its own background while the animation runs. ([#48838](https://github.com/expo/expo/pull/48838) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
35
+ - [Android] Add a Jetpack Compose `Image` component. ([#48521](https://github.com/expo/expo/pull/48521) by [@jakex7](https://github.com/jakex7))
36
+
37
+ ### 🐛 Bug fixes
38
+
39
+ - [universal] Fix `Cannot use shared object that was already released` when a worklet callback prop closes over an unstable value. ([#48819](https://github.com/expo/expo/pull/48819) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
40
+
13
41
  ## 57.0.10 — 2026-08-10
14
42
 
15
43
  ### 🎉 New features
@@ -12,13 +12,13 @@ apply plugin: 'expo-module-gradle-plugin'
12
12
  apply plugin: 'org.jetbrains.kotlin.plugin.compose'
13
13
 
14
14
  group = 'expo.modules.ui'
15
- version = '57.0.10'
15
+ version = '57.0.12'
16
16
 
17
17
  android {
18
18
  namespace "expo.modules.ui"
19
19
  defaultConfig {
20
20
  versionCode 1
21
- versionName "57.0.10"
21
+ versionName "57.0.12"
22
22
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
23
23
  }
24
24
  buildFeatures {
@@ -28,7 +28,9 @@ import expo.modules.ui.button.IconButtonContent
28
28
  import expo.modules.ui.button.FilledIconButtonContent
29
29
  import expo.modules.ui.button.FilledTonalIconButtonContent
30
30
  import expo.modules.ui.button.OutlinedIconButtonContent
31
+ import expo.modules.ui.graphics.ImageLoader
31
32
  import expo.modules.ui.icon.IconView
33
+ import expo.modules.ui.image.ImageView
32
34
  import expo.modules.ui.menu.DropdownMenuContent
33
35
  import expo.modules.ui.menu.DropdownMenuProps
34
36
  import expo.modules.ui.menu.DropdownMenuItemContent
@@ -55,15 +57,23 @@ import okhttp3.OkHttpClient
55
57
  class ExpoUIModule : Module() {
56
58
  var okHttpClient: OkHttpClient? = null
57
59
  private set
60
+ var imageLoader: ImageLoader? = null
61
+ private set
58
62
 
59
63
  override fun definition() = ModuleDefinition {
60
64
  Name("ExpoUI")
61
65
 
62
66
  OnCreate {
63
- okHttpClient = OkHttpClient.Builder().build()
67
+ val context = requireNotNull(appContext.reactContext) {
68
+ "ExpoUIModule requires an active React context"
69
+ }
70
+ okHttpClient = appContext.createOkHttpClientBuilder().build()
71
+ imageLoader = ImageLoader(context, requireNotNull(okHttpClient))
64
72
  }
65
73
 
66
74
  OnDestroy {
75
+ imageLoader?.close()
76
+ imageLoader = null
67
77
  okHttpClient?.dispatcher?.executorService?.shutdown()
68
78
  okHttpClient?.connectionPool?.evictAll()
69
79
  okHttpClient?.cache?.close()
@@ -79,6 +89,13 @@ class ExpoUIModule : Module() {
79
89
  callback
80
90
  }
81
91
 
92
+ Function("setWorklet") { callback: WorkletCallback, worklet: Worklet ->
93
+ // Dispatch on main thread as callback is read on the main thread
94
+ appContext.mainQueue.launch {
95
+ callback.worklet = worklet
96
+ }
97
+ }
98
+
82
99
  Property("__expo_ui_shared_object__") { _: WorkletCallback ->
83
100
  true
84
101
  }
@@ -167,6 +184,9 @@ class ExpoUIModule : Module() {
167
184
  View(InnerTextFieldView::class)
168
185
  View(PlaceholderView::class)
169
186
  View(IconView::class)
187
+ View(ImageView::class) {
188
+ Events("onLoad", "onError")
189
+ }
170
190
  View(LazyColumnView::class)
171
191
  View(LazyRowView::class)
172
192
 
@@ -0,0 +1,43 @@
1
+ package expo.modules.ui.graphics
2
+
3
+ import android.graphics.drawable.BitmapDrawable
4
+ import android.graphics.drawable.Drawable
5
+ import androidx.compose.runtime.Composable
6
+ import androidx.compose.runtime.remember
7
+ import androidx.compose.ui.geometry.Size
8
+ import androidx.compose.ui.graphics.asImageBitmap
9
+ import androidx.compose.ui.graphics.drawscope.DrawScope
10
+ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
11
+ import androidx.compose.ui.graphics.nativeCanvas
12
+ import androidx.compose.ui.graphics.painter.BitmapPainter
13
+ import androidx.compose.ui.graphics.painter.Painter
14
+
15
+ @Composable
16
+ internal fun rememberDrawablePainter(drawable: Drawable?): Painter? {
17
+ return remember(drawable) {
18
+ when (drawable) {
19
+ null -> null
20
+ is BitmapDrawable -> BitmapPainter(drawable.bitmap.asImageBitmap())
21
+ else -> DrawablePainter(drawable.mutate())
22
+ }
23
+ }
24
+ }
25
+
26
+ private class DrawablePainter(
27
+ private val drawable: Drawable
28
+ ) : Painter() {
29
+ override val intrinsicSize: Size
30
+ get() = Size(
31
+ drawable.intrinsicWidth.toFloat().takeIf { it > 0 } ?: Size.Unspecified.width,
32
+ drawable.intrinsicHeight.toFloat().takeIf { it > 0 } ?: Size.Unspecified.height
33
+ )
34
+
35
+ override fun DrawScope.onDraw() {
36
+ drawIntoCanvas { canvas ->
37
+ with(drawable) {
38
+ setBounds(0, 0, size.width.toInt(), size.height.toInt())
39
+ draw(canvas.nativeCanvas)
40
+ }
41
+ }
42
+ }
43
+ }
@@ -1,135 +1,155 @@
1
- package expo.modules.ui.icon
1
+ package expo.modules.ui.graphics
2
2
 
3
3
  import android.content.Context
4
4
  import android.content.res.XmlResourceParser
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.drawable.BitmapDrawable
5
7
  import android.graphics.drawable.Drawable
6
8
  import android.net.Uri
7
9
  import android.util.Log
10
+ import android.util.LruCache
8
11
  import android.util.Xml
12
+ import androidx.compose.ui.graphics.PathFillType
9
13
  import androidx.compose.ui.graphics.SolidColor
10
14
  import androidx.compose.ui.graphics.vector.ImageVector
11
15
  import androidx.compose.ui.graphics.vector.PathParser
12
16
  import androidx.compose.ui.unit.Dp
13
17
  import androidx.compose.ui.unit.dp
18
+ import androidx.core.graphics.drawable.toDrawable
14
19
  import androidx.core.graphics.toColorInt
20
+ import androidx.core.net.toUri
21
+ import expo.modules.kotlin.okhttp.await
22
+ import kotlinx.coroutines.CancellationException
15
23
  import kotlinx.coroutines.Dispatchers
16
24
  import kotlinx.coroutines.withContext
17
25
  import okhttp3.OkHttpClient
18
26
  import okhttp3.Request
19
27
  import org.xmlpull.v1.XmlPullParser
20
28
  import java.io.File
21
- import java.io.InputStream
29
+ import java.io.IOException
22
30
 
23
31
  /**
24
- * Loads and parses vector icons from various sources (HTTP, file, content provider).
32
+ * Loads and parses images from various sources (HTTP, file, content provider).
25
33
  * Supports XML vector drawables (Android format) and bitmap images.
26
34
  *
27
35
  * All loading operations are suspend functions and run on appropriate dispatchers.
28
36
  */
29
- class VectorIconLoader(
37
+ class ImageLoader(
30
38
  private val context: Context,
31
39
  private val okHttpClient: OkHttpClient
32
40
  ) {
41
+ private val imageCache = object : LruCache<String, ImageResult>(memoryCacheSizeKilobytes()) {
42
+ override fun sizeOf(key: String, value: ImageResult): Int {
43
+ val bitmap = (value.drawable as? BitmapDrawable)?.bitmap ?: return 1
44
+
45
+ return (bitmap.allocationByteCount / 1024)
46
+ .coerceAtLeast(1)
47
+ }
48
+ }
33
49
 
34
50
  /**
35
- * Result of icon loading operation.
51
+ * Result of an image loading operation.
36
52
  */
37
- data class IconResult(
53
+ data class ImageResult(
38
54
  val imageVector: ImageVector? = null,
39
- val drawable: Drawable? = null
40
- )
55
+ val drawable: Drawable? = null,
56
+ val error: String? = null
57
+ ) {
58
+ val isSuccess: Boolean
59
+ get() = imageVector != null || drawable != null
60
+ }
41
61
 
42
62
  /**
43
- * Load an icon from a URI string.
44
- * Returns an IconResult containing either an ImageVector (for XML) or Drawable (for bitmaps).
63
+ * Load an image from a URI string.
64
+ * Returns an ImageResult containing either an ImageVector (for XML) or Drawable (for bitmaps).
45
65
  *
46
66
  * This is a suspend function that performs I/O operations on the IO dispatcher.
47
67
  */
48
- suspend fun loadFromUri(uriString: String?): IconResult = withContext(Dispatchers.IO) {
49
- if (uriString.isNullOrEmpty()) return@withContext IconResult()
68
+ suspend fun loadFromUri(uriString: String?): ImageResult = withContext(Dispatchers.IO) {
69
+ if (uriString.isNullOrEmpty()) {
70
+ return@withContext ImageResult(error = "Image URI is empty")
71
+ }
50
72
 
51
73
  val uri = try {
52
- Uri.parse(uriString)
74
+ uriString.toUri()
53
75
  } catch (e: Exception) {
54
76
  Log.e(TAG, "Failed to parse URI: $uriString", e)
55
- return@withContext IconResult()
77
+ return@withContext ImageResult(error = "Failed to parse image URI: $uriString")
56
78
  }
57
79
 
58
- try {
59
- // For resource URIs, use Android's XmlResourceParser which handles compiled binary XML
60
- if (uri.scheme == "res") {
61
- return@withContext loadFromResourceId(uri)
80
+ imageCache.get(uriString)?.let { return@withContext it }
81
+ loadUncached(uri).also { result ->
82
+ if (result.isSuccess) {
83
+ imageCache.put(uriString, result)
62
84
  }
63
-
64
- val inputStream = getInputStreamForUri(uri) ?: return@withContext IconResult()
65
- inputStream.use { stream ->
66
- parseIconFromStream(stream)
67
- }
68
- } catch (e: Exception) {
69
- Log.e(TAG, "Failed to load icon from URI: $uri", e)
70
- IconResult()
71
85
  }
72
86
  }
73
87
 
74
88
  /**
75
- * Get input stream for different URI schemes.
89
+ * Clears pending work and releases decoded images held by this loader.
76
90
  */
77
- private fun getInputStreamForUri(uri: Uri): InputStream? {
78
- return when (uri.scheme) {
79
- "file" -> {
80
- val path = uri.path ?: return null
81
- File(path).inputStream()
82
- }
83
- "content" -> {
84
- context.contentResolver.openInputStream(uri)
85
- }
86
- "http", "https" -> {
87
- downloadFromHttp(uri.toString())
88
- }
89
- "android.resource" -> {
90
- context.contentResolver.openInputStream(uri)
91
- }
92
- else -> {
93
- context.contentResolver.openInputStream(uri)
91
+ fun close() {
92
+ imageCache.evictAll()
93
+ }
94
+
95
+ private suspend fun loadUncached(uri: Uri): ImageResult {
96
+ return try {
97
+ // For resource URIs, use Android's XmlResourceParser which handles compiled binary XML.
98
+ if (uri.scheme == "res") {
99
+ loadFromResourceId(uri)
100
+ } else {
101
+ val bytes = when (uri.scheme) {
102
+ "file" -> {
103
+ val path = uri.path ?: throw IOException("Image file URI has no path: $uri")
104
+ File(path).readBytes()
105
+ }
106
+
107
+ "content", "android.resource" -> {
108
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
109
+ ?: throw IOException("Unable to open image URI: $uri")
110
+ }
111
+
112
+ "http", "https" -> downloadFromHttp(uri.toString())
113
+ else -> throw IOException("Unsupported image URI scheme: ${uri.scheme ?: "none"}")
114
+ }
115
+ parseImageFromBytes(bytes)
94
116
  }
117
+ } catch (e: CancellationException) {
118
+ throw e
119
+ } catch (e: Exception) {
120
+ val message = e.message ?: "Failed to load image from URI: $uri"
121
+ Log.e(TAG, message, e)
122
+ ImageResult(error = message)
95
123
  }
96
124
  }
97
125
 
98
126
  /**
99
- * Download icon from HTTP/HTTPS URL using OkHttp.
100
- * Uses OkHttp's blocking call which is safe on IO dispatcher.
127
+ * Download an image from HTTP/HTTPS URL using OkHttp.
128
+ * The OkHttp call is cancelled when the enclosing coroutine is cancelled.
101
129
  */
102
- private fun downloadFromHttp(url: String): InputStream? {
103
- return try {
104
- val request = Request.Builder()
105
- .url(url)
106
- .build()
107
-
108
- // OkHttp's execute() is blocking, which is fine on IO dispatcher
109
- val response = okHttpClient.newCall(request).execute()
130
+ private suspend fun downloadFromHttp(url: String): ByteArray {
131
+ val request = Request.Builder()
132
+ .url(url)
133
+ .build()
134
+ return okHttpClient.newCall(request).await().use { response ->
110
135
  if (!response.isSuccessful) {
111
- Log.e(TAG, "Failed to download icon from $url: ${response.code}")
112
- response.close()
113
- return null
136
+ throw IOException("Failed to download image from $url: HTTP ${response.code}")
114
137
  }
115
-
116
- response.body?.byteStream()
117
- } catch (e: Exception) {
118
- Log.e(TAG, "HTTP download failed: $url", e)
119
- null
138
+ response.body?.bytes()
139
+ ?: throw IOException("Image response from $url has no body")
120
140
  }
121
141
  }
122
142
 
123
143
  /**
124
- * Load icon from an Android resource ID extracted from a `res:/` URI.
144
+ * Load an image from an Android resource ID extracted from a `res:/` URI.
125
145
  * Uses [XmlResourceParser] which handles compiled (binary) XML in release builds.
126
146
  */
127
- private fun loadFromResourceId(uri: Uri): IconResult {
147
+ private fun loadFromResourceId(uri: Uri): ImageResult {
128
148
  val resourceId = uri.lastPathSegment?.toIntOrNull()
129
149
  ?: uri.path?.trimStart('/')?.toIntOrNull()
130
150
  if (resourceId == null || resourceId <= 0) {
131
151
  Log.w(TAG, "Invalid resource ID in URI: $uri")
132
- return IconResult()
152
+ return ImageResult(error = "Invalid image resource ID: $uri")
133
153
  }
134
154
 
135
155
  // Try parsing as XML (handles both text and compiled binary XML)
@@ -138,38 +158,40 @@ class VectorIconLoader(
138
158
  xmlParser.use { parser ->
139
159
  val imageVector = parseXmlToImageVector(parser)
140
160
  if (imageVector != null) {
141
- return IconResult(imageVector = imageVector)
161
+ return ImageResult(imageVector = imageVector)
142
162
  }
143
163
  }
144
164
  } catch (e: Exception) {
145
165
  Log.d(TAG, "Resource $resourceId is not XML, trying as raw resource", e)
146
166
  }
147
167
 
148
- // Fall back to raw resource stream (bitmap images, etc.)
168
+ // Fall back to bitmap resource decoding.
149
169
  try {
150
- context.resources.openRawResource(resourceId).use { stream ->
151
- val drawable = Drawable.createFromStream(stream, null)
152
- return IconResult(drawable = drawable)
153
- }
170
+ val bitmap = BitmapFactory.decodeResource(context.resources, resourceId)
171
+ ?: return ImageResult(error = "Failed to decode image resource $resourceId")
172
+ return ImageResult(drawable = bitmap.toDrawable(context.resources))
154
173
  } catch (e: Exception) {
155
174
  Log.e(TAG, "Failed to load resource $resourceId", e)
156
- return IconResult()
175
+ return ImageResult(error = e.message ?: "Failed to load image resource $resourceId")
157
176
  }
158
177
  }
159
178
 
160
179
  /**
161
- * Parse icon from input stream.
180
+ * Parse an image from encoded bytes.
162
181
  * Detects format (XML or bitmap) and returns appropriate result.
163
182
  */
164
- private fun parseIconFromStream(inputStream: InputStream): IconResult {
165
- val bytes = inputStream.readBytes()
166
-
183
+ private fun parseImageFromBytes(bytes: ByteArray): ImageResult {
167
184
  return if (isXmlContent(bytes)) {
168
185
  val imageVector = parseXmlToImageVector(bytes)
169
- IconResult(imageVector = imageVector)
186
+ if (imageVector != null) {
187
+ ImageResult(imageVector = imageVector)
188
+ } else {
189
+ ImageResult(error = "Failed to decode XML image")
190
+ }
170
191
  } else {
171
- val drawable = Drawable.createFromStream(bytes.inputStream(), null)
172
- IconResult(drawable = drawable)
192
+ val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
193
+ ?: return ImageResult(error = "Failed to decode bitmap image")
194
+ ImageResult(drawable = bitmap.toDrawable(context.resources))
173
195
  }
174
196
  }
175
197
 
@@ -181,10 +203,7 @@ class VectorIconLoader(
181
203
 
182
204
  // Check for UTF-8 BOM
183
205
  var offset = 0
184
- if (bytes.size >= 3 &&
185
- bytes[0] == 0xEF.toByte() &&
186
- bytes[1] == 0xBB.toByte() &&
187
- bytes[2] == 0xBF.toByte()
206
+ if (bytes[0] == 0xEF.toByte() && bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte()
188
207
  ) {
189
208
  offset = 3
190
209
  }
@@ -275,6 +294,7 @@ class VectorIconLoader(
275
294
  // Note: groups, clips, gradients not yet supported
276
295
  }
277
296
  }
297
+
278
298
  XmlPullParser.END_TAG -> {
279
299
  if (parser.name == "vector") {
280
300
  return builder.build()
@@ -298,6 +318,7 @@ class VectorIconLoader(
298
318
  try {
299
319
  var pathData = ""
300
320
  var fillColor: androidx.compose.ui.graphics.Color? = null
321
+ var pathFillType = PathFillType.NonZero
301
322
 
302
323
  for (i in 0 until parser.attributeCount) {
303
324
  when (parser.getAttributeName(i)) {
@@ -305,7 +326,13 @@ class VectorIconLoader(
305
326
  "fillColor" -> {
306
327
  fillColor = parseColor(parser.getAttributeValue(i))
307
328
  }
308
- // Note: stroke properties, fillType, opacity not yet supported
329
+ "fillType" -> {
330
+ pathFillType = when (parser.getAttributeValue(i)) {
331
+ "evenOdd", "1" -> PathFillType.EvenOdd
332
+ else -> PathFillType.NonZero
333
+ }
334
+ }
335
+ // Note: stroke properties and opacity are not yet supported
309
336
  }
310
337
  }
311
338
 
@@ -313,7 +340,8 @@ class VectorIconLoader(
313
340
  val nodes = PathParser().parsePathString(pathData).toNodes()
314
341
  builder.addPath(
315
342
  pathData = nodes,
316
- fill = fillColor?.let { SolidColor(it) }
343
+ fill = fillColor?.let { SolidColor(it) },
344
+ pathFillType = pathFillType
317
345
  )
318
346
  }
319
347
  } catch (e: Exception) {
@@ -332,7 +360,7 @@ class VectorIconLoader(
332
360
  .replace("px", "")
333
361
  .toFloat()
334
362
  numericValue.dp
335
- } catch (e: Exception) {
363
+ } catch (_: Exception) {
336
364
  Log.w(TAG, "Failed to parse dimension: $value, using default 24dp")
337
365
  24.dp
338
366
  }
@@ -347,11 +375,13 @@ class VectorIconLoader(
347
375
  colorValue.startsWith("#") -> {
348
376
  androidx.compose.ui.graphics.Color(colorValue.toColorInt())
349
377
  }
378
+
350
379
  colorValue.startsWith("@android:color/") || colorValue.startsWith("?attr/") -> {
351
380
  // Theme attributes default to black (could be enhanced to resolve theme colors)
352
381
  Log.d(TAG, "Theme color attribute not resolved: $colorValue, using black")
353
382
  androidx.compose.ui.graphics.Color.Black
354
383
  }
384
+
355
385
  else -> {
356
386
  Log.w(TAG, "Unknown color format: $colorValue, using black")
357
387
  androidx.compose.ui.graphics.Color.Black
@@ -364,6 +394,11 @@ class VectorIconLoader(
364
394
  }
365
395
 
366
396
  companion object {
367
- private const val TAG = "VectorIconLoader"
397
+ private const val TAG = "ImageLoader"
398
+
399
+ private fun memoryCacheSizeKilobytes(): Int {
400
+ val maxMemoryKilobytes = Runtime.getRuntime().maxMemory() / 1024
401
+ return (maxMemoryKilobytes / 8).coerceIn(1, Int.MAX_VALUE.toLong()).toInt()
402
+ }
368
403
  }
369
404
  }
@@ -0,0 +1,28 @@
1
+ package expo.modules.ui.graphics
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import expo.modules.kotlin.records.Field
6
+ import expo.modules.kotlin.records.Record
7
+ import expo.modules.kotlin.types.OptimizedRecord
8
+
9
+ @OptimizedRecord
10
+ data class ImageSource(
11
+ @Field val uri: String,
12
+ @Field val width: Int = 0,
13
+ @Field val height: Int = 0,
14
+ @Field val scale: Double = 1.0
15
+ ) : Record
16
+
17
+ internal fun ImageSource.resolveUri(context: Context): String? {
18
+ return try {
19
+ val parsedUri = Uri.parse(uri)
20
+ if (parsedUri.scheme == null) {
21
+ ResourceIdHelper.getResourceUri(context, uri)?.toString()
22
+ } else {
23
+ uri
24
+ }
25
+ } catch (_: Exception) {
26
+ ResourceIdHelper.getResourceUri(context, uri)?.toString()
27
+ }
28
+ }
@@ -1,4 +1,4 @@
1
- package expo.modules.ui.icon
1
+ package expo.modules.ui.graphics
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context