update: latest source changes

This commit is contained in:
2026-05-31 20:50:37 +02:00
parent 858674c9d4
commit d88187be16
156 changed files with 11885 additions and 4 deletions
+17
View File
@@ -4510,6 +4510,23 @@
"react-native": "*"
}
},
"node_modules/expo-image": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.11.tgz",
"integrity": "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-image-loader": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
+2
View File
@@ -0,0 +1,2 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');
+770
View File
@@ -0,0 +1,770 @@
# Changelog
## Unpublished
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
### 💡 Others
## 3.0.11 — 2025-12-05
_This version does not introduce any user-facing changes._
## 3.0.10 — 2025-10-20
_This version does not introduce any user-facing changes._
## 3.0.9 — 2025-10-07
### 🐛 Bug fixes
- [Android] Fixed `You can't start or clear loads in RequestListener or Target callbacks`. ([#40212](https://github.com/expo/expo/pull/40212) by [@lukmccall](https://github.com/lukmccall))
## 3.0.8 — 2025-09-11
_This version does not introduce any user-facing changes._
## 3.0.7 — 2025-09-03
### 🐛 Bug fixes
- [iOS] Fix images not displaying in Material Top Tabs navigator. ([#39323](https://github.com/expo/expo/pull/39323) by [@lukmccall](https://github.com/lukmccall))
## 3.0.6 — 2025-09-02
_This version does not introduce any user-facing changes._
## 3.0.5 — 2025-08-31
_This version does not introduce any user-facing changes._
## 3.0.4 — 2025-08-26
### 🐛 Bug fixes
- [Android] Fixed `The method 'getResourceDrawableUri' was expected to be of type static` exception. ([#39143](https://github.com/expo/expo/pull/39143) by [@lukmccall](https://github.com/lukmccall))
## 3.0.3 — 2025-08-25
_This version does not introduce any user-facing changes._
## 3.0.2 — 2025-08-16
_This version does not introduce any user-facing changes._
## 3.0.1 — 2025-08-15
_This version does not introduce any user-facing changes._
## 3.0.0 — 2025-08-13
### 🎉 New features
- Add `generateThumbhashAsync` ([#38090](https://github.com/expo/expo/pull/38090) by [@Wenszel](https://github.com/Wenszel))
- Add support for `ImageRef` source in `generateBlurhashAsync` ([#37901](https://github.com/expo/expo/pull/37901) by [@Wenszel](https://github.com/Wenszel))
- [Android] Add generateBlurhashAsync ([#37817](https://github.com/expo/expo/pull/37817) by [@Wenszel](https://github.com/Wenszel))
### 🐛 Bug fixes
- [Android] Fix animation resuming by casting image to GifDrawable. ([#37363](https://github.com/expo/expo/pull/37363) by [@Wenszel](https://github.com/Wenszel))
- [Web] Fix `alt` as an alias for `accessibilityLabel` ([#37682](https://github.com/expo/expo/pull/37682) by [@huextrat](https://github.com/huextrat))
- [iOS] Fix caching resized images from Photo Library. ([#38105](https://github.com/expo/expo/pull/38105) by [@jakex7](https://github.com/jakex7))
- [iOS] Fix `generatePlaceholder` method syntax error by removing unwanted trailing comma. ([#38318](https://github.com/expo/expo/pull/38318) by [@bortolilucas](https://github.com/bortolilucas))
## 2.4.0 - 2025-07-17
### 🎉 New features
- [iOS] Add a new prop - `enforceEarlyResizing` to reduce the memory usage of the image view. ([#37909](https://github.com/expo/expo/pull/37909) by [@lukmccall](https://github.com/lukmccall))
### 🐛 Bug fixes
- [iOS] Speed up displaying local assets. ([#37795](https://github.com/expo/expo/pull/37795) by [@aleqsio](https://github.com/aleqsio))
- [iOS] Fix some operation were incorrectly cancelled. ([#37987](https://github.com/expo/expo/pull/37987) by [@lukmccall](https://github.com/lukmccall))
## 2.3.2 - 2025-07-01
### 🐛 Bug fixes
- [iOS] Use specified cache type when no transformation is applied ([#37777](https://github.com/expo/expo/pull/37777) by [@jakex7](https://github.com/jakex7))
## 2.3.1 - 2025-07-01
### 🐛 Bug fixes
- [iOS] Fixed contentPosition is not correct after switching theme. ([#37374](https://github.com/expo/expo/pull/37374) by [@kudo](https://github.com/kudo))
### 📚 3rd party library updates
- [Android] Bumped GIF Glide plugin to 3.0.5 for Android 16KB page size support. ([#37454](https://github.com/expo/expo/pull/37454) by [@kudo](https://github.com/kudo))
## 2.3.0 - 2025-06-11
### 🛠 Breaking changes
- [iOS] `useAppleWebpCodec` has been moved from the source object to the component's prop to make it usable with the local assets. ([#37300](https://github.com/expo/expo/pull/37300) by [@tsapeta](https://github.com/tsapeta))
### 🐛 Bug fixes
- [iOS] Fix blurry images when using `tintColor` by scaling `imageThumbnailPixelSize` with screen density. ([#37235](https://github.com/expo/expo/pull/37235) by [@hirbod](https://github.com/hirbod))
## 2.2.1 - 2025-06-10
_This version does not introduce any user-facing changes._
## 2.2.0 - 2025-06-04
### 🎉 New features
- Add imperative api to lock/unlock/reload resource. ([#36912](https://github.com/expo/expo/pull/36912) by [@jakex7](https://github.com/jakex7))
### 🐛 Bug fixes
- Fix React Server Components support. ([#36801](https://github.com/expo/expo/pull/36801) by [@EvanBacon](https://github.com/EvanBacon))
- [iOS] Fix PhotoLibrary assets being scaled twice. ([#36776](https://github.com/expo/expo/pull/36776) by [@alanjhughes](https://github.com/alanjhughes))
- [iOS] Don't add transformers when unnecessary. ([#36884](https://github.com/expo/expo/pull/36884) by [@jakex7](https://github.com/jakex7))
- [Web] Fix `tintColor` in React 19. ([#37133](https://github.com/expo/expo/pull/37133) by [@bradleyayers](https://github.com/bradleyayers))
## 2.1.7 — 2025-05-06
_This version does not introduce any user-facing changes._
## 2.1.6 — 2025-04-30
_This version does not introduce any user-facing changes._
## 2.1.5 — 2025-04-25
### 🐛 Bug fixes
- Fixed `CUICatalog: Invalid asset name supplied: ''` error on iOS when the path is empty. ([#36294](https://github.com/expo/expo/pull/36294) by [@Innei](https://github.com/Innei))
## 2.1.4 — 2025-04-14
### 🐛 Bug fixes
- Fixed SVG image tinting on iOS. ([#35927](https://github.com/expo/expo/pull/35927) by [@kudo](https://github.com/kudo))
- [Android] Fixed OutOfMemoryError crash when displaying some gif images ([#36097](https://github.com/expo/expo/pull/36097) by [@rahimrahman](https://github.com/rahimrahman))
## 2.1.3 — 2025-04-11
_This version does not introduce any user-facing changes._
## 2.1.2 — 2025-04-09
_This version does not introduce any user-facing changes._
## 2.1.1 — 2025-04-08
### 🐛 Bug fixes
- Fixed SVG image tinting on iOS. ([#35927](https://github.com/expo/expo/pull/35927) by [@kudo](https://github.com/kudo))
## 2.1.0 — 2025-04-04
### 🛠 Breaking changes
- upgrade RN to 0.78 ([#35050](https://github.com/expo/expo/pull/35050) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- type-compatibility with react-native 0.77 ([#34027](https://github.com/expo/expo/pull/34027) by [@vonovak](https://github.com/vonovak))
- Added `ImageSource.useAppleWebpCodec` on iOS to allow using libwebp codec for better animation performance on some images. ([#35802](https://github.com/expo/expo/pull/35802) by [@kudo](https://github.com/kudo))
### 🐛 Bug fixes
- Rename `fetchpriority` prop to `fetchPriority` to silence web error. ([#35411](https://github.com/expo/expo/pull/35411) by [@EvanBacon](https://github.com/EvanBacon))
- Fixed `tintColor="currentColor"` conflicts on web. ([#34604](https://github.com/expo/expo/pull/34604) by [@bradleyayers](https://github.com/bradleyayers))
### 💡 Others
- Update `ImageProps` type so `children` are omitted. ([#33210](https://github.com/expo/expo/pull/33210) by [@ashaller2017](https://github.com/ashaller2017))
- [Android] Started using expo modules gradle plugin. ([#34176](https://github.com/expo/expo/pull/34176) by [@lukmccall](https://github.com/lukmccall))
- [iOS] Fix warnings which will become errors in Swift 6. ([#35428](https://github.com/expo/expo/pull/35428) by [@behenate](https://github.com/behenate))
### 📚 3rd party library updates
- Bumped `SDWebImage` to 5.21.0. ([#35795](https://github.com/expo/expo/pull/35795) by [@kudo](https://github.com/kudo))
## 2.0.7 - 2025-03-26
### 🐛 Bug fixes
- [iOS] Fixed image be cropped with `contentPosition` on New Architecture mode. ([#35630](https://github.com/expo/expo/pull/35630) by [@kudo](https://github.com/kudo))
## 2.0.6 - 2025-02-19
### 🐛 Bug fixes
- [Android] Fixes a regression in `loadAsync` from [#34767](https://github.com/expo/expo/pull/34767). ([#34965](https://github.com/expo/expo/pull/34965) by [@alanjhughes](https://github.com/alanjhughes)) ([#34767](https://github.com/expo/expo/pull/34767), [#34965](https://github.com/expo/expo/pull/34965) by [@alanjhughes](https://github.com/alanjhughes))
## 2.0.5 - 2025-02-10
### 🐛 Bug fixes
- [Android] Add headers to `loadAsync` requests. ([#34767](https://github.com/expo/expo/pull/34767) by [@alanjhughes](https://github.com/alanjhughes))
## 2.0.4 - 2025-01-10
_This version does not introduce any user-facing changes._
## 2.0.3 - 2024-11-29
### 🐛 Bug fixes
- [Android] Fixed `useImage` causing a native crash when uri is unresolvable. ([#33268](https://github.com/expo/expo/pull/33268) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fixes adding a blurhash placeholder to `Image` or `ImageBackground` causing blurry version of the image. ([#33272](https://github.com/expo/expo/pull/33272) by [@lukmccall](https://github.com/lukmccall))
## 2.0.2 — 2024-11-22
### 💡 Others
- Add a warning when `children` are passed to `Image`. ([#33139](https://github.com/expo/expo/pull/33139) by [@alanjhughes](https://github.com/alanjhughes))
## 2.0.1 — 2024-11-19
### 🐛 Bug fixes
- [Android] Fixes Gif animations never stopping even when loop count is set to 1. ([#32944](https://github.com/expo/expo/pull/32944) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fixed `borderColor` is not applied. ([#33026](https://github.com/expo/expo/pull/33026) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fixed `border` related props weren't applied correctly. ([#33078](https://github.com/expo/expo/pull/33078) by [@lukmccall](https://github.com/lukmccall))
### 💡 Others
- [Android] migrate ImageView to CSSBackgroundDrawable ([#33024](https://github.com/expo/expo/pull/33024) by [@vonovak](https://github.com/vonovak))
## 2.0.0 — 2024-11-11
_This version does not introduce any user-facing changes._
## 2.0.0-preview.1 — 2024-10-31
### 🐛 Bug fixes
- Fixed displaying a placeholder when `useImage` hook is used. ([#32430](https://github.com/expo/expo/pull/32430) by [@tsapeta](https://github.com/tsapeta))
## 2.0.0-preview.0 — 2024-10-22
### 🛠 Breaking changes
- Bumped iOS and tvOS deployment target to 15.1. ([#30840](https://github.com/expo/expo/pull/30840), [#30871](https://github.com/expo/expo/pull/30871) by [@tsapeta](https://github.com/tsapeta))
### 🎉 New features
- Added `Image.loadAsync` API. ([#25079](https://github.com/expo/expo/pull/25079) by [@tsapeta](https://github.com/tsapeta), [#26824](https://github.com/expo/expo/pull/26824) by [@aleqsio](https://github.com/aleqsio), [#31575](https://github.com/expo/expo/pull/31575) by [@tsapeta](https://github.com/tsapeta))
- Add basic React Server Component support. ([#29869](https://github.com/expo/expo/pull/29869) by [@EvanBacon](https://github.com/EvanBacon))
- [iOS] Added support for rendering shared image refs. ([#30661](https://github.com/expo/expo/pull/30661) by [@tsapeta](https://github.com/tsapeta))
- [Android] Added support for rendering shared image refs. ([#31098](https://github.com/expo/expo/pull/31098) by [@lukmccall](https://github.com/lukmccall))
- [Android] Added support for rendering shared refs of `Bitmap's`. ([#31440](https://github.com/expo/expo/pull/31440) by [@lukmccall](https://github.com/lukmccall))
- Added `useImage` hook. ([#31171](https://github.com/expo/expo/pull/31171) by [@tsapeta](https://github.com/tsapeta))
- Added downscaling options to `useImage` hook. ([#32113](https://github.com/expo/expo/pull/32113) by [@lukmccall](https://github.com/lukmccall))
### 🐛 Bug fixes
- Fix props not being passed to parent container. ([#29416](https://github.com/expo/expo/pull/29416) by [@aleqsio](https://github.com/aleqsio))
- Add missing `react` and `react-native` peer dependencies for isolated modules. ([#30469](https://github.com/expo/expo/pull/30469) by [@byCedric](https://github.com/byCedric))
- Add missing `react-native-web` optional peer dependency for isolated modules. ([#30689](https://github.com/expo/expo/pull/30689) by [@byCedric](https://github.com/byCedric))
- [Web] Fix type incompatibility between style prop and `@types/react-native-web` ([#31150](https://github.com/expo/expo/pull/31150) by [@adamhari](https://github.com/adamhari))
- [iOS] Fixed `isAnimated` property always returning `true`. ([#31834](https://github.com/expo/expo/pull/31834) by [@tsapeta](https://github.com/tsapeta))
- [iOS] Fixed `stopAnimating` broken on downscaled animated images. ([#32053](https://github.com/expo/expo/pull/32053) by [@kudo](https://github.com/kudo))
### 💡 Others
- Use the `src` folder as the Metro target. ([#30665](https://github.com/expo/expo/pull/30665) by [@tsapeta](https://github.com/tsapeta))
- Provide image's memory footprint for better garbage collection. ([#31168](https://github.com/expo/expo/pull/31168) by [@tsapeta](https://github.com/tsapeta) & [#31784](https://github.com/expo/expo/pull/31784) by [@lukmccall](https://github.com/lukmccall))
### ⚠️ Notices
- Added support for React Native 0.75.x. ([#30034](https://github.com/expo/expo/pull/30034) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- Added support for React Native 0.76.x. ([#31552](https://github.com/expo/expo/pull/31552) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## 1.13.0 - 2024-09-23
### 🎉 New features
- Added `onDisplay` event. ([#31581](https://github.com/expo/expo/pull/31581) by [@tsapeta](https://github.com/tsapeta))
## 1.12.15 - 2024-08-24
_This version does not introduce any user-facing changes._
## 1.12.13 - 2024-07-16
### 🐛 Bug fixes
- [Web] Fix blurhash not working and causing fetches to `blurhash:/` uri. ([#27587](https://github.com/expo/expo/pull/27587) by [@aleqsio](https://github.com/aleqsio))
- [iOS] Fixed `blurRadius` not working. Also made the effect render more consistently across platforms. ([#29678](https://github.com/expo/expo/pull/29678) by [@vonovak](https://github.com/vonovak))
- Fixed `tintColor` not working on Safari browsers. ([#29169](https://github.com/expo/expo/pull/29169) by [@bradleyayers](https://github.com/bradleyayers))
- Fixed reanimated support on web. ([#29197](https://github.com/expo/expo/pull/29197) by [@nishan](https://github.com/intergalacticspacehighway)) ([#29197](https://github.com/expo/expo/pull/29197) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
## 1.12.12 - 2024-06-13
### 💡 Others
- Removed @react-native/assets-registry dependency. ([#29541](https://github.com/expo/expo/pull/29541) by [@kudo](https://github.com/kudo))
## 1.12.11 - 2024-06-06
_This version does not introduce any user-facing changes._
## 1.12.10 - 2024-06-05
### 💡 Others
- Pin @react-native subpackage versions to 0.74.83. ([#29441](https://github.com/expo/expo/pull/29441) by [@kudo](https://github.com/kudo))
## 1.12.9 — 2024-05-09
### 💡 Others
- Added setup for native unit tests. ([#28678](https://github.com/expo/expo/pull/28678) by [@tsapeta](https://github.com/tsapeta))
## 1.12.8 — 2024-05-06
_This version does not introduce any user-facing changes._
## 1.12.7 — 2024-05-04
### 🎉 New features
- Added support for displaying animated AVIF images on Android. ([#28609](https://github.com/expo/expo/pull/28609) by [@fobos531](https://github.com/fobos531))
### 🐛 Bug fixes
- Fix `avif` images not rendering. ([#28608](https://github.com/expo/expo/pull/28608) by [@alanjhughes](https://github.com/alanjhughes))
## 1.12.6 — 2024-05-02
_This version does not introduce any user-facing changes._
## 1.12.5 — 2024-05-01
_This version does not introduce any user-facing changes._
## 1.12.4 — 2024-04-24
### 🐛 Bug fixes
- Fixed an issue where certain images would not animate at certain sizes on `iOS`. ([#28335](https://github.com/expo/expo/pull/28335) by [@fobos531](https://github.com/fobos531))
- Fixed SVG assets without viewbox attribute not being rendered on Android. ([#28369](https://github.com/expo/expo/pull/28369) by [@lukmccall](https://github.com/lukmccall))
## 1.12.3 — 2024-04-23
_This version does not introduce any user-facing changes._
## 1.12.2 — 2024-04-22
### 🐛 Bug fixes
- Fixed an issue where certain images would not animate at certain sizes on `iOS`. ([#28335](https://github.com/expo/expo/pull/28335) by [@fobos531](https://github.com/fobos531), [#28371](https://github.com/expo/expo/pull/28371) by [@tsapeta](https://github.com/tsapeta))
## 1.12.1 — 2024-04-19
_This version does not introduce any user-facing changes._
## 1.12.0 — 2024-04-18
### 🎉 New features
- On `iOS`, support loading assets in the native project. This already worked on android. ([#27251](https://github.com/expo/expo/pull/27251) by [@alanjhughes](https://github.com/alanjhughes))
- Support prefetching images with HTTP headers. ([#28133](https://github.com/expo/expo/pull/28133) by [@toy0605](https://github.com/toy0605))
### 🐛 Bug fixes
- [iOS] Fixed an issue where data URIs caused crashes on iOS16+. ([#28320](https://github.com/expo/expo/pull/28320) by [@aleqsio](https://github.com/aleqsio))
- Refactor web implementations of `useSourceSelection` to avoid unnecessary rerenders. ([#27569](https://github.com/expo/expo/pull/27569) by [@aleqsio](https://github.com/aleqsio))
- Fixed an issue where `isBlurhashString` always returned `true` .([#27251](https://github.com/expo/expo/pull/27251) by [@alanjhughes](https://github.com/alanjhughes))
- Fix #available check to account for tvOS. ([#27272](https://github.com/expo/expo/pull/27272) by [@alanjhughes](https://github.com/alanjhughes))
- Fixed jest may cause `RangeError: Invalid string length` error when generating a snapshot. ([#27354](https://github.com/expo/expo/pull/27354) by [@lukmccall](https://github.com/lukmccall))
- Fixed placeholders weren't correctly scaled down on the Android. ([#28255](https://github.com/expo/expo/pull/28255) by [@lukmccall](https://github.com/lukmccall))
### 💡 Others
- [iOS] Bump SDWebImage to `5.19.1` to include the 3rd-party privacy manifest. ([#27874](https://github.com/expo/expo/pull/27874) by [@aparedes](https://github.com/aparedes), []() by [@tsapeta](https://github.com/tsapeta))
- Use `typeof window` checks for removing server code. ([#27514](https://github.com/expo/expo/pull/27514) by [@EvanBacon](https://github.com/EvanBacon))
- Removed deprecated backward compatible Gradle settings. ([#28083](https://github.com/expo/expo/pull/28083) by [@kudo](https://github.com/kudo))
- [iOS] Bump SDWebImageWebPCoder to `0.14.6`. ([#28273](https://github.com/expo/expo/pull/28273) by [@alanjhughes](https://github.com/alanjhughes))
- Automatically clean blurhash cache when the app's memory is running low. ([#28276](https://github.com/expo/expo/pull/28276) by [@lukmccall](https://github.com/lukmccall))
## 1.11.0 — 2024-02-05
### 🎉 New features
- [Android] Adds new prop `decodeFormat` to specify the format that should be used during the decoding process. ([#26442](https://github.com/expo/expo/pull/26442) by [@lukmccall](https://github.com/lukmccall))
- [iOS] Added `generateBlurhashAsync` function. ([#26430](https://github.com/expo/expo/pull/26430) by [@aleqsio](https://github.com/aleqsio))
- [Android] Adds automatic downsampling when the asset exceeds the hardware bitmap size limit. ([#26792](https://github.com/expo/expo/pull/26792) by [@lukmccall](https://github.com/lukmccall))
### 🐛 Bug fixes
- Fixed ResizeObserver attaching on every image transition. ([#25819](https://github.com/expo/expo/pull/25819) by [@aleqsio](https://github.com/aleqsio))
- [Android] Fixed the tine color was applied to the mask element. ([#26323](https://github.com/expo/expo/pull/26323) by [@lukmccall](https://github.com/lukmccall))
- [Web] Fixed `nativeViewRef` invalid prop warning. ([#25922](https://github.com/expo/expo/pull/25922) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
## 1.10.4 - 2024-01-18
_This version does not introduce any user-facing changes._
## 1.10.3 - 2024-01-12
_This version does not introduce any user-facing changes._
## 1.10.2 - 2024-01-10
### 🐛 Bug fixes
- [Android] Fixed the issue with the application of tint color when an element does not have a style assigned to it. ([#26251](https://github.com/expo/expo/pull/26251) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fixed the tint color wasn't applied to the root element. ([#26339](https://github.com/expo/expo/pull/26339) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fixed that the tint color on SVGs can't be changed dynamically. ([#26350](https://github.com/expo/expo/pull/26350) by [@lukmccall](https://github.com/lukmccall))
### 💡 Others
- Replace deprecated `com.facebook.react:react-native:+` Android dependency with `com.facebook.react:react-android`. ([#26237](https://github.com/expo/expo/pull/26237) by [@kudo](https://github.com/kudo))
## 1.10.1 - 2023-12-19
_This version does not introduce any user-facing changes._
## 1.10.0 — 2023-12-12
### 🎉 New features
- Added support for React Native 0.73.0. ([#24971](https://github.com/expo/expo/pull/24971), [#25453](https://github.com/expo/expo/pull/25453) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## 1.9.0 — 2023-11-14
### 🛠 Breaking changes
- Bumped iOS deployment target to 13.4. ([#25063](https://github.com/expo/expo/pull/25063) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- On `Android` bump `compileSdkVersion` and `targetSdkVersion` to `34`. ([#24708](https://github.com/expo/expo/pull/24708) by [@alanjhughes](https://github.com/alanjhughes))
### 🎉 New features
- [Android] The stability of the memory cache key generation has been improved. ([#25372](https://github.com/expo/expo/pull/25372) by [@lukmccall](https://github.com/lukmccall))
### 💡 Others
- Collapse re-export of `react-native/Libraries/Image/AssetRegistry` to `@react-native/assets-registry/registry`. ([#25265](https://github.com/expo/expo/pull/25265) by [@EvanBacon](https://github.com/EvanBacon))
- [Android] Changed how the `tintColor` is applied to the SVG. ([#25377](https://github.com/expo/expo/pull/25377) by [@lukmccall](https://github.com/lukmccall))
## 1.8.0 — 2023-11-13
### 🎉 New features
- Return a promise in the `prefetch` method. ([#25196](https://github.com/expo/expo/pull/25196) by [@gkasdorf](https://github.com/gkasdorf))
- [Android] Added `autoplay` prop and `startAnimating()` and `stopAnimating()` functions to reflect changes made to iOS in [#25008](https://github.com/expo/expo/pull/25008). ([#25124](https://github.com/expo/expo/pull/25124) by [@gkasdorf](https://github.com/gkasdorf))
### 🐛 Bug fixes
- [Android] Fix `contentFit` not working for `SVG` images. ([#25187](https://github.com/expo/expo/pull/25187) by [@behenate](https://github.com/behenate))
- [iOS] Start loading the image before the view mounts to fix issues with the PagerView. ([#25343](https://github.com/expo/expo/pull/25343) by [@tsapeta](https://github.com/tsapeta))
- [Android] Fix `SVG` not scaling correctly in the release mode. ([#25326](https://github.com/expo/expo/pull/25326) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fix incorrect `intrinsicSize` returned for SVGs. ([#25048](https://github.com/expo/expo/pull/25048) by [@behenate](https://github.com/behenate))
### 💡 Others
- [Android] Add tracing. ([#25251](https://github.com/expo/expo/pull/25251) by [@lukmccall](https://github.com/lukmccall))
## 1.7.0 — 2023-11-01
### 🎉 New features
- [iOS] Added support for `allowDownscaling` prop. ([#25012](https://github.com/expo/expo/pull/25012) by [@behenate](https://github.com/behenate))
- Added `getCachePathAsync()` to retrieve the path of the cached image file if it exists ([#24980](https://github.com/expo/expo/pull/24980) by [@gkasdorf](https://github.com/gkasdorf))
- [iOS] Added `autoplay` prop to control whether an animated image will automatically animate or not ([#25008](https://github.com/expo/expo/pull/25008) by [@gkasdorf](https://github.com/gkasdorf))
- [iOS] Added `startAnimating()` and `stopAnimating()` functions to start or stop an image's animation ([#25008](https://github.com/expo/expo/pull/25008) by [@gkasdorf](https://github.com/gkasdorf))
### 🐛 Bug fixes
- [Android] fix crash when loading local image files with no file extension ([#24201](https://github.com/expo/expo/pull/25032) by [@kadikraman](https://github.com/kadikraman))
- [iOS] Fix compilation on tvOS. ([#25010](https://github.com/expo/expo/pull/25010) by [@douglowder](https://github.com/douglowder))
- [iOS] Fixed issue where some animated images would cause the app to hang ([#25008](https://github.com/expo/expo/pull/25008) by [@gkasdorf](https://github.com/gkasdorf))
## 1.6.1 — 2023-11-01
### 🐛 Bug fixes
- [Android] fix crash when loading local image files with no file extension ([#24201](https://github.com/expo/expo/pull/25032) by [@kadikraman](https://github.com/kadikraman))
## 1.3.5 — 2023-11-01
### 🐛 Bug fixes
- [Android] fix crash when loading local image files with no file extension ([#24201](https://github.com/expo/expo/pull/25032) by [@kadikraman](https://github.com/kadikraman))
## 1.6.0 — 2023-10-17
### 🛠 Breaking changes
- Dropped support for Android SDK 21 and 22. ([#24201](https://github.com/expo/expo/pull/24201) by [@behenate](https://github.com/behenate))
### 🎉 New features
- Added support for the `headers` key in the `source` object on web. ([#24447](https://github.com/expo/expo/pull/24447) by [@aleqsio](https://github.com/aleqsio))
- Add support for setting `tintColor` on SVGs on Android (part 1) ([#24733](https://github.com/expo/expo/pull/24733) by [@alanjhughes](https://github.com/alanjhughes) and [@kadikraman](https://github.com/kadikraman))
- Add support for setting `tintColor` on SVGs on Android (part 2) ([#24888](https://github.com/expo/expo/pull/24888) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- Remove `GlideWebpDecoder` until they update their `libwebp` dependency. ([#24656](https://github.com/expo/expo/pull/24656) by [@alanjhughes](https://github.com/alanjhughes))
- [web] Fix content fit not being applied correctly when using hash placeholders. ([#24542](https://github.com/expo/expo/pull/24542) by [@aleqsio](https://github.com/aleqsio))
- [macCatalyst] Fix build with `ImageAnalyzer` on macCatalyst below 17.0. ([#24880](https://github.com/expo/expo/pull/24880) by [@kesha-antonov](https://github.com/kesha-antonov))
### 💡 Others
- Ship untranspiled JSX to support custom handling of `jsx` and `createElement`. ([#24889](https://github.com/expo/expo/pull/24889) by [@EvanBacon](https://github.com/EvanBacon))
- Make `placeholderContentFit` visible in the docs. ([#24801](https://github.com/expo/expo/pull/24801) by [@behenate](https://github.com/behenate))
## 1.0.2 — 2023-09-29
### 🐛 Bug fixes
- Remove `GlideWebpDecoder` until they update their `libwebp` dependency. ([#24656](https://github.com/expo/expo/pull/24656) by [@alanjhughes](https://github.com/alanjhughes))
## 1.3.4 — 2023-09-28
### 🐛 Bug fixes
- Remove `GlideWebpDecoder` until they update their `libwebp` dependency. ([#24656](https://github.com/expo/expo/pull/24656) by [@alanjhughes](https://github.com/alanjhughes))
## 1.5.2 — 2023-09-18
_This version does not introduce any user-facing changes._
## 1.3.3 — 2023-09-15
### 🐛 Bug fixes
- Fixed placeholders aren't always replaced by full-size images on Android. ([#23705](https://github.com/expo/expo/pull/23705) by [@lukmccall](https://github.com/lukmccall))
- Fix the image components rendering incorrect assets when the Proguard is enabled on Android. ([#23704](https://github.com/expo/expo/pull/23704) by [@lukmccall](https://github.com/lukmccall))
- Fixed gif and awebp memory leak on Android. ([#24259](https://github.com/expo/expo/pull/24259) by [@jingpeng](https://github.com/jingpeng))
- Suppress "Operation cancelled by user during sending the request" error when the load request is canceled (interrupted) by a new one. ([#24279](https://github.com/expo/expo/pull/24279) by [@tsapeta](https://github.com/tsapeta))
## 1.5.1 — 2023-09-11
### 🐛 Bug fixes
- Suppress "Operation cancelled by user during sending the request" error when the load request is canceled (interrupted) by a new one. ([#24279](https://github.com/expo/expo/pull/24279) by [@tsapeta](https://github.com/tsapeta))
- Fixed gif and awebp memory leak on Android. ([#24259](https://github.com/expo/expo/pull/24259) by [@jingpeng](https://github.com/jingpeng))
## 1.5.0 — 2023-09-04
### 🎉 New features
- Added support for React Native 0.73. ([#24018](https://github.com/expo/expo/pull/24018) by [@kudo](https://github.com/kudo))
### 💡 Others
- On iOS, bump SDWebImage versions. ([#23858](https://github.com/expo/expo/pull/23858) by [@alanjhughes](https://github.com/alanjhughes))
## 1.4.1 — 2023-08-02
_This version does not introduce any user-facing changes._
## 1.4.0 — 2023-07-28
### 🎉 New features
- [Web] Add support for `tintColor` prop on web. ([#23434](https://github.com/expo/expo/pull/23434) by [@aleqsio](https://github.com/aleqsio))
- [Web] Add support for static image responsiveness using `srcset` attributes. ([#22088](https://github.com/expo/expo/pull/22088) by [@aleqsio](https://github.com/aleqsio))
### 🐛 Bug fixes
- Fixed placeholders aren't always replaced by full-size images on Android. ([#23705](https://github.com/expo/expo/pull/23705) by [@lukmccall](https://github.com/lukmccall))
- Fixed the image components rendering incorrect assets when the Proguard is enabled on Android. ([#23704](https://github.com/expo/expo/pull/23704) by [@lukmccall](https://github.com/lukmccall))
### 💡 Others
- [Web] Refactored and split some functions for better readability. ([#23465](https://github.com/expo/expo/pull/23465) by [@aleqsio](https://github.com/aleqsio))
## 1.3.2 - 2023-07-12
### 🐛 Bug fixes
- [iOS] Fixed `tintColor` prop not working for SVGs. ([#23418](https://github.com/expo/expo/pull/23418) by [@tsapeta](https://github.com/tsapeta))
## 1.3.1 - 2023-06-29
### 🐛 Bug fixes
- Fixed an issue where recyclingKey would reset the image source on mount. ([#23187](https://github.com/expo/expo/pull/23187) by [@hirbod](https://github.com/hirbod))
## 1.3.0 — 2023-06-13
### 🎉 New features
- Add prefetch implementation on web. ([#22630](https://github.com/expo/expo/pull/22630) by [@aleqsio](https://github.com/aleqsio))
- Add `ImageBackground` component. ([#22347](https://github.com/expo/expo/pull/22347) by [@alanjhughes](https://github.com/alanjhughes))
- Added support for React Native 0.72. ([#22588](https://github.com/expo/expo/pull/22588) by [@kudo](https://github.com/kudo))
### 🐛 Bug fixes
- Fixed styles order breaking layouting on web. ([#22630](https://github.com/expo/expo/pull/22630) by [@aleqsio](https://github.com/aleqsio))
- Uses prop spreading on web to pass all unused props to the native image component ([#22340](https://github.com/expo/expo/pull/22340) by [@makkarMeenu](https://github.com/makkarMeenu))
- Fixed Android build warnings for Gradle version 8. ([#22537](https://github.com/expo/expo/pull/22537), [#22609](https://github.com/expo/expo/pull/22609) by [@kudo](https://github.com/kudo))
### 💡 Others
- Updated `SDWebImage` to `5.15.8`, `SDWebImageWebPCoder` to `0.11.0` and `SDWebImageSVGCoder` to `1.7.0`. ([#22576](https://github.com/expo/expo/pull/22576) by [@tsapeta](https://github.com/tsapeta))
## 1.2.3 — 2023-05-16
### 🐛 Bug fixes
- Upgrade SDWebImageAVIFCoder to fix compiling issue with libavif < 0.11.0. ([#22491](https://github.com/expo/expo/pull/22491) by [@matinzd](https://github.com/matinzd))
## 1.2.2 — 2023-04-27
### 🐛 Bug fixes
- Fix for the "limited" media library permission. ([#22261](https://github.com/expo/expo/pull/22261) by [@tsapeta](https://github.com/tsapeta))
## 1.2.1 — 2023-04-17
### 🐛 Bug fixes
- [Android] Fix `url` property returned by the `onLoad` event. ([#22161](https://github.com/expo/expo/pull/22161) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fix images not loading after the app was foregrounded. ([#22159](https://github.com/expo/expo/pull/22159) by [@lukmccall](https://github.com/lukmccall))
- [Android] Fixed image was loaded event if the view dimensions were 0. ([#22157](https://github.com/expo/expo/pull/22157) by [@lukmccall](https://github.com/lukmccall))
- Fix generating the image from ThumbHash that starts with a slash character. ([#22160](https://github.com/expo/expo/pull/22160) by [@tsapeta](https://github.com/tsapeta))
## 1.2.0 — 2023-04-14
### 🎉 New features
- [Web] Add support for `require()` assets. ([#21798](https://github.com/expo/expo/pull/21798) by [@aleqsio](https://github.com/aleqsio))
- Add `alt` prop as an alias to `accessibilityLabel`. ([#21884](https://github.com/expo/expo/pull/21884) by [@EvanBacon](https://github.com/EvanBacon))
- [Web] Add `accessibilityLabel` support on web. ([#21884](https://github.com/expo/expo/pull/21884) by [@EvanBacon](https://github.com/EvanBacon))
- Added `ThumbHash` support for Android, iOS and Web. ([#21952](https://github.com/expo/expo/pull/21952) by [@behenate](https://github.com/behenate))
### 🐛 Bug fixes
- [Web] Improve transition behavior when switching back and forth two sources. ([#22099](https://github.com/expo/expo/pull/22099) by [@aleqsio](https://github.com/aleqsio))
- [Web] Prevent breaking in static rendering environments. ([#21883](https://github.com/expo/expo/pull/21883) by [@EvanBacon](https://github.com/EvanBacon))
- [Android] Fixed image disappearing before navigation animation is complete. ([#22066](https://github.com/expo/expo/pull/22066) by [@sallen450](https://github.com/sallen450))
- [Web] Fixed monorepo asset resolution in production for Metro web. ([#22094](https://github.com/expo/expo/pull/22094) by [@EvanBacon](https://github.com/EvanBacon))
## 1.1.0 — 2023-03-25
### 🎉 New features
- [Android] Add automatic asset downscaling to improve performance. ([#21628](https://github.com/expo/expo/pull/21628) by [@lukmccall](https://github.com/lukmccall))
### 🐛 Bug fixes
- Fixed the `tintColor` not being passed to native view. ([#21576](https://github.com/expo/expo/pull/21576) by [@andrew-levy](https://github.com/andrew-levy))
- Fixed `canvas: trying to use a recycled bitmap` on Android. ([#21658](https://github.com/expo/expo/pull/21658) by [@lukmccall](https://github.com/lukmccall))
- Fixed crashes caused by empty placeholder or source on Android. ([#21695](https://github.com/expo/expo/pull/21695) by [@lukmccall](https://github.com/lukmccall))
- Fixes `shouldDownscale` don't respect the scale factor on iOS. ([#21839](https://github.com/expo/expo/pull/21839) by [@ouabing](https://github.com/ouabing))
- Fixes cache policy not being correctly applied when set to `none` on iOS. ([#21840](https://github.com/expo/expo/pull/21840) by [@ouabing](https://github.com/ouabing))
## 1.0.0 — 2023-02-21
_This version does not introduce any user-facing changes._
## 1.0.0-rc.2 — 2023-02-20
### 🎉 New features
- Added `recyclingKey` prop that allows reseting the image view content when the view is recycled. ([#21297](https://github.com/expo/expo/pull/21297) & [#21309](https://github.com/expo/expo/pull/21309) by [@tsapeta](https://github.com/tsapeta) & [@lukmccall](https://github.com/lukmccall))
## 1.0.0-rc.1 — 2023-02-14
### 🐛 Bug fixes
- Fixed `You can't start or clear loads in RequestListener or Target callbacks` on Android. ([#21192](https://github.com/expo/expo/pull/21192) by [@lukmccall](https://github.com/lukmccall))
- Fixed SVGs are not rendered in the release mode on Android. ([#21214](https://github.com/expo/expo/pull/21214) by [@lukmccall](https://github.com/lukmccall))
- Stop sending `onProgress` event when the asset size is unknown which led to diving by zero and a crash. ([#21215](https://github.com/expo/expo/pull/21215) by [@tsapeta](https://github.com/tsapeta))
## 1.0.0-rc.0 — 2023-02-09
### 🎉 New features
- Added `placeholderContentFit` prop implementation on the web. ([#21106](https://github.com/expo/expo/pull/21106) by [@aleqsio](https://github.com/aleqsio))
## 1.0.0-beta.6 — 2023-02-06
### 🎉 New features
- Added new prop `placeholderContentFit` to specify custom content fit on the placeholder. ([#21096](https://github.com/expo/expo/pull/21096) by [@magrinj](https://github.com/magrinj))
### 🐛 Bug fixes
- [iOS] Fixed possible freezes by processing images concurrently off the main thread. ([#21086](https://github.com/expo/expo/pull/21086) by [@tsapeta](https://github.com/tsapeta))
## 1.0.0-beta.5 — 2023-02-03
_This version does not introduce any user-facing changes._
## 1.0.0-beta.4 — 2023-01-31
### 🐛 Bug fixes
- Fixed a crash on Android where `isScreenReaderFocusable` crashes devices below api 28. ([#21012](https://github.com/expo/expo/pull/21012) by [@alanhughes](https://github.com/alanjhughes))
## 1.0.0-beta.3 — 2023-01-30
### 🐛 Bug fixes
- Fixed a crash on iOS below 16.0 introduced by the Live Text interaction feature. ([#20987](https://github.com/expo/expo/pull/20987) by [@tsapeta](https://github.com/tsapeta))
## 1.0.0-beta.2 — 2023-01-25
### 🎉 New features
- Added support for Live text interaction. ([#20915](https://github.com/expo/expo/pull/20915) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
### 🐛 Bug fixes
- `ImageProps` now extends `ViewProps`. ([#20942](https://github.com/expo/expo/pull/20942) by [@appden](https://github.com/appden))
## 1.0.0-beta.1 — 2023-01-20
### 🐛 Bug fixes
- Use `SDImageAWebPCoder` on iOS 14+ to speed up loading WebP images. ([#20897](https://github.com/expo/expo/pull/20897) by [@tsapeta](https://github.com/tsapeta))
### 💡 Others
- On Android bump `compileSdkVersion` and `targetSdkVersion` to `33`. ([#20721](https://github.com/expo/expo/pull/20721) by [@lukmccall](https://github.com/lukmccall))
- Upgraded `SDWebImage` to `5.15.0` and `SDWebImageAVIFCoder` to `0.9.4`. ([#20898](https://github.com/expo/expo/pull/20898) by [@tsapeta](https://github.com/tsapeta))
## 1.0.0-beta.0 — 2023-01-19
### 🎉 New features
- Added support for crossfade transition on Android. ([#20784](https://github.com/expo/expo/pull/20784) by [@lukmccall](https://github.com/lukmccall))
- Added web support for the `blurRadius` prop. ([#20845](https://github.com/expo/expo/pull/20845) by [@aleqsio](https://github.com/aleqsio))
- Support for `accessible` and `accessibilityLabel` props on Android. ([#20801](https://github.com/expo/expo/pull/20801) by [@lukmccall](https://github.com/lukmccall))
- Support for `accessible` and `accessibilityLabel` props on iOS. ([#20892](https://github.com/expo/expo/pull/20892) by [@alanhughes](https://github.com/alanjhughes))
## 1.0.0-alpha.6 — 2023-01-10
### 🎉 New features
- Introduced the `source.cacheKey` parameter to customize the key used for caching the source image. ([#20772](https://github.com/expo/expo/pull/20772) by [@tsapeta](https://github.com/tsapeta), [#20776](https://github.com/expo/expo/pull/20776) by [@lukmccall](https://github.com/lukmccall))
## 1.0.0-alpha.5 — 2023-01-04
### 🎉 New features
- Added support for assets from the iOS Photo Library (`ph://` urls). ([#20700](https://github.com/expo/expo/pull/20700) by [@tsapeta](https://github.com/tsapeta))
### 🐛 Bug fixes
- Fixed `ImageProps` type not allowing an array of styles. ([#20701](https://github.com/expo/expo/pull/20701) by [@tsapeta](https://github.com/tsapeta))
## 1.0.0-alpha.4 — 2022-12-30
### 🐛 Bug fixes
- Fixed compatibility with `react-native-shared-element` on iOS. ([#20592](https://github.com/expo/expo/pull/20592) by [@IjzerenHein](https://github.com/ijzerenhein))
## 1.0.0-alpha.3 — 2022-12-21
### 🎉 New features
- Initial release 🥳
+64
View File
@@ -0,0 +1,64 @@
<p>
<a href="https://docs.expo.dev/versions/unversioned/sdk/image/">
<img
src="../../.github/resources/expo-image.svg"
alt="expo-image"
height="64" />
</a>
</p>
A cross-platform, performant image component for React Native and Expo.
## Main features
- Designed for speed
- Support for many image formats (including animated ones)
- Disk and memory caching
- Supports [BlurHash](https://blurha.sh) and [ThumbHash](https://evanw.github.io/thumbhash/) - compact representations of a placeholder for an image
- Transitioning between images when the source changes (no more flickering!)
- Implements the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) and [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) properties (see [`contentFit`](#contentfit) and [`contentPosition`](#contentposition) props)
- Uses performant [`SDWebImage`](https://github.com/SDWebImage/SDWebImage) and [`Glide`](https://github.com/bumptech/glide) under the hood
## Supported image formats
| Format | Android | iOS | Web |
| :--------: | :-----: | :-: | :--------------------------------------------: |
| WebP | ✅ | ✅ | ✅ |
| PNG / APNG | ✅ | ✅ | ✅ |
| AVIF | ✅ | ✅ | ✅ |
| HEIC | ✅ | ✅ | ❌ [not adopted yet](https://caniuse.com/heif) |
| JPEG | ✅ | ✅ | ✅ |
| GIF | ✅ | ✅ | ✅ |
| SVG | ✅ | ✅ | ✅ |
| ICO | ✅ | ✅ | ✅ |
| ICNS | ❌ | ✅ | ❌ |
# API documentation
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/image/)
# Installation in managed Expo projects
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, follow the installation instructions in the [API documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/image/).
# Installation in bare React Native projects
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
### Add the package to your npm dependencies
```
npx expo install expo-image
```
### Configure for iOS
Run `npx pod-install` after installing the npm package.
### Configure for Android
No additional setup necessary.
# Contributing
Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
+45
View File
@@ -0,0 +1,45 @@
plugins {
id 'com.android.library'
id 'kotlin-kapt'
id 'expo-module-gradle-plugin'
}
android {
namespace "expo.modules.image"
defaultConfig {
versionCode 1
versionName "3.0.11"
consumerProguardFiles("proguard-rules.pro")
buildConfigField("boolean", "ALLOW_GLIDE_LOGS", project.properties.get("EXPO_ALLOW_GLIDE_LOGS", "false"))
}
sourceSets {
main {
java {
if (expoModule.safeExtGet("excludeAppGlideModule", false)) {
exclude("**/ExpoImageAppGlideModule.kt")
}
}
}
}
}
dependencies {
def GLIDE_VERSION = "4.16.0"
implementation 'com.facebook.react:react-android'
api "com.github.bumptech.glide:glide:${GLIDE_VERSION}"
kapt "com.github.bumptech.glide:compiler:${GLIDE_VERSION}"
api 'com.caverock:androidsvg-aar:1.4'
implementation "com.github.penfeizhou.android.animation:glide-plugin:3.0.5"
implementation "com.github.bumptech.glide:avif-integration:${GLIDE_VERSION}"
api 'com.github.bumptech.glide:okhttp3-integration:4.11.0'
api "com.squareup.okhttp3:okhttp:${expoModule.safeExtGet("okHttpVersion", '4.9.2')}"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1'
implementation "jp.wasabeef:glide-transformations:4.3.0"
}
+28
View File
@@ -0,0 +1,28 @@
# https://bumptech.github.io/glide/doc/download-setup.html#proguard
-keep public class * extends com.bumptech.glide.module.LibraryGlideModule
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep class * extends com.bumptech.glide.module.AppGlideModule {
<init>(...);
}
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
*** rewind();
}
-keep public class com.bumptech.glide.request.ThumbnailRequestCoordinator {
*;
}
-dontwarn com.bumptech.glide.load.resource.bitmap.VideoDecoder
# https://bumptech.github.io/glide/doc/configuration.html#applications
-keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
-keep public class com.bumptech.glide.integration.webp.WebpImage { *; }
-keep public class com.bumptech.glide.integration.webp.WebpFrame { *; }
-keep public class com.bumptech.glide.integration.webp.WebpBitmapFactory { *; }
+17
View File
@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Begin Glide configuration -->
<!-- Internet access (https://bumptech.github.io/glide/doc/download-setup.html#internet) -->
<uses-permission android:name="android.permission.INTERNET" />
<!--
Allows Glide to monitor connectivity status and restart failed requests if users go from a
a disconnected to a connected network state.
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Local storage (https://bumptech.github.io/glide/doc/download-setup.html#local-storage) -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- End Glide configuration -->
</manifest>
@@ -0,0 +1,125 @@
package com.caverock.androidsvg
import com.caverock.androidsvg.SVG.SPECIFIED_COLOR
import com.caverock.androidsvg.SVG.SPECIFIED_FILL
import com.caverock.androidsvg.SVG.SvgElementBase
internal fun replaceColor(paint: SVG.SvgPaint?, newColor: Int) {
if (paint is SVG.Colour && paint !== SVG.Colour.TRANSPARENT) {
paint.colour = newColor
}
}
internal fun replaceStyles(style: SVG.Style?, newColor: Int) {
if (style == null) {
return
}
replaceColor(style.color, newColor)
replaceColor(style.fill, newColor)
replaceColor(style.stroke, newColor)
replaceColor(style.stopColor, newColor)
replaceColor(style.solidColor, newColor)
replaceColor(style.viewportFill, newColor)
}
internal fun hasStyle(element: SvgElementBase): Boolean {
if (element.style == null && element.baseStyle == null) {
return false
}
val style = element.style
val hasColorInStyle = style != null &&
(
style.color != null || style.fill != null || style.stroke != null ||
style.stroke != null || style.stopColor != null || style.solidColor != null
)
if (hasColorInStyle) {
return true
}
val baseStyle = element.baseStyle ?: return false
return baseStyle.color != null || baseStyle.fill != null || baseStyle.stroke != null ||
baseStyle.viewportFill != null || baseStyle.stopColor != null || baseStyle.solidColor != null
}
internal fun defineStyles(element: SvgElementBase, newColor: Int, hasStyle: Boolean) {
if (hasStyle) {
return
}
val style = if (element.style != null) {
element.style
} else {
SVG.Style().also {
element.style = it
}
}
val color = SVG.Colour(newColor)
when (element) {
is SVG.Path,
is SVG.Circle,
is SVG.Ellipse,
is SVG.Rect,
is SVG.SolidColor,
is SVG.Line,
is SVG.Polygon,
is SVG.PolyLine -> {
style.apply {
fill = color
specifiedFlags = SPECIFIED_FILL
}
}
is SVG.TextPath -> {
style.apply {
this.color = color
specifiedFlags = SPECIFIED_COLOR
}
}
}
}
internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefinesStyle: Boolean) {
// We want to keep the colors in the mask as they control the visibility of the element to which the mask is applied.
if (element is SVG.Mask) {
return
}
val definesStyle = if (element is SvgElementBase) {
val hasStyle = parentDefinesStyle || hasStyle(element)
replaceStyles(element.baseStyle, newColor)
replaceStyles(element.style, newColor)
defineStyles(element, newColor, hasStyle)
hasStyle
} else {
parentDefinesStyle
}
if (element is SVG.SvgContainer) {
for (child in element.children) {
applyTintColor(child, newColor, definesStyle)
}
}
}
fun applyTintColor(svg: SVG, newColor: Int) {
val root = svg.rootElement
svg.cssRules?.forEach { rule ->
replaceStyles(rule.style, newColor)
}
replaceStyles(root.baseStyle, newColor)
replaceStyles(root.style, newColor)
val hasStyle = hasStyle(root)
for (child in root.children) {
applyTintColor(child, newColor, hasStyle)
}
}
@@ -0,0 +1,226 @@
package expo.modules.image
import android.os.Build
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.Target
import expo.modules.image.enums.ContentFit
import expo.modules.image.records.DecodeFormat
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* Glide uses `hashCode` and `equals` of the `DownsampleStrategy` to calculate the cache key.
* However, we generate this object dynamically, which means that each instance will be different.
* Unfortunately, this behaviour is not correct since Glide will not load
* the image from memory no matter what.
* To fix this issue, we set the `hashCode` to a fixed number and
* override `equals` to only check if objects have the common type.
*/
abstract class CustomDownsampleStrategy : DownsampleStrategy() {
override fun equals(other: Any?): Boolean {
return other is CustomDownsampleStrategy
}
override fun hashCode(): Int {
return 302008237
}
}
object NoopDownsampleStrategy : DownsampleStrategy() {
override fun getScaleFactor(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): Float = 1f
override fun getSampleSizeRounding(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): SampleSizeRounding = SampleSizeRounding.QUALITY
}
class PlaceholderDownsampleStrategy(
private val target: ImageViewWrapperTarget
) : CustomDownsampleStrategy() {
private var wasTriggered = false
override fun getScaleFactor(sourceWidth: Int, sourceHeight: Int, requestedWidth: Int, requestedHeight: Int): Float {
if (!wasTriggered) {
target.placeholderWidth = sourceWidth
target.placeholderHeight = sourceHeight
wasTriggered = true
}
return 1f
}
override fun getSampleSizeRounding(sourceWidth: Int, sourceHeight: Int, requestedWidth: Int, requestedHeight: Int): SampleSizeRounding {
return SampleSizeRounding.QUALITY
}
}
class ContentFitDownsampleStrategy(
private val target: ImageViewWrapperTarget,
private val contentFit: ContentFit
) : CustomDownsampleStrategy() {
private var wasTriggered = false
override fun getScaleFactor(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): Float {
// The method is invoked twice per asset, but we only need to preserve the original dimensions for the first call.
// As Glide uses Android downsampling, it can only adjust dimensions by a factor of two,
// and hence two distinct scaling factors are computed to achieve greater accuracy.
if (!wasTriggered) {
target.sourceWidth = sourceWidth
target.sourceHeight = sourceHeight
wasTriggered = true
}
// The size of the container is unknown, we don't know what to do, so we just run the default scale.
if (requestedWidth == Target.SIZE_ORIGINAL || requestedHeight == Target.SIZE_ORIGINAL) {
return 1f
}
val aspectRation = calculateScaleFactor(
sourceWidth.toFloat(),
sourceHeight.toFloat(),
requestedWidth.toFloat(),
requestedHeight.toFloat()
)
// We don't want to upscale the image
return min(1f, aspectRation)
}
private fun calculateScaleFactor(
sourceWidth: Float,
sourceHeight: Float,
requestedWidth: Float,
requestedHeight: Float
): Float = when (contentFit) {
ContentFit.Contain -> min(
requestedWidth / sourceWidth,
requestedHeight / sourceHeight
)
ContentFit.Cover -> java.lang.Float.max(
requestedWidth / sourceWidth,
requestedHeight / sourceHeight
)
ContentFit.ScaleDown -> if (requestedWidth < sourceWidth || requestedHeight < sourceHeight) {
// The container is smaller than the image — scale it down and behave like `contain`
min(
requestedWidth / sourceWidth,
requestedHeight / sourceHeight
)
} else {
// The container is bigger than the image — don't scale it and behave like `none`
1f
}
else -> 1f
}
override fun getSampleSizeRounding(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
) = SampleSizeRounding.QUALITY
}
/**
* Android has hardware bitmap size limit that can be drown on the canvas.
* To prevents crashes, we need to downsample the image to fit into the maximum bitmap size.
*/
class SafeDownsampleStrategy(
private val decodeFormat: DecodeFormat
) : CustomDownsampleStrategy() {
override fun getScaleFactor(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): Float {
if (maxBitmapSize <= 0) {
return 1f
}
val sourceSize = sourceWidth * sourceHeight * decodeFormat.toBytes()
if (sourceSize <= maxBitmapSize) {
return 1f
}
// We need to downsample the image to fit into the maximum bitmap size.
// Calculate the aspect ratio of the source image. It's always <= 1.
val srcAspectRatio = min(sourceWidth, sourceHeight).toDouble() / max(sourceWidth, sourceHeight).toDouble()
// Calculate the area of the destination image.
val dstArea = maxBitmapSize / decodeFormat.toBytes()
// Calculate the longer side of the destination image using following formulas:
// dstLongerSide * dstSmallerSide = dstArea
// srcAspectRation * dstLongerSide = dstSmallerSide
// after substitution:
// srcAspectRation * dstLongerSide * dstLongerSide = dstArea
// dstLongerSide = sqrt(dstArea / srcAspectRatio)
val x = floor(sqrt(dstArea.toDouble() / srcAspectRatio)).toInt()
// Calculate the scale factor using longer side of both images.
val scaleFactor = x.toDouble() / max(sourceWidth, sourceHeight).toDouble()
return scaleFactor.toFloat()
}
override fun getSampleSizeRounding(
sourceWidth: Int,
sourceHeight: Int,
requestedWidth: Int,
requestedHeight: Int
): SampleSizeRounding {
return SampleSizeRounding.MEMORY
}
private val maxBitmapSize by lazy {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return@lazy -1
}
return@lazy try {
val defaultSize = 100 * 1024 * 1024 // 100 MB - from `RecordingCanvas` src
// We're trying to get the value of `ro.hwui.max_texture_allocation_size` property
// which is used by `RecordingCanvas` to determine the maximum bitmap size.
@Suppress("PrivateApi")
val getIntMethod = Class
.forName("android.os.SystemProperties")
.getMethod("getInt", String::class.java, Int::class.java)
getIntMethod.isAccessible = true
(getIntMethod.invoke(null, "ro.hwui.max_texture_allocation_size", defaultSize) as Int)
.coerceAtLeast(defaultSize)
} catch (e: Throwable) {
// If something goes wrong we just return -1 and don't downsample the image.
-1
}
}
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other !is SafeDownsampleStrategy) {
return false
}
return decodeFormat == other.decodeFormat
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + decodeFormat.hashCode()
return result
}
}
@@ -0,0 +1,8 @@
package expo.modules.image
import com.bumptech.glide.load.Option
object CustomOptions {
// To pass the tint color to the SVG decoder, we need to wrap it in a custom Glide option.
val tintColor = Option.memory<Int>("ExpoTintColor")
}
@@ -0,0 +1,6 @@
package expo.modules.image
import expo.modules.kotlin.exception.CodedException
class ImageLoadFailed(exception: Exception) :
CodedException(message = "Failed to load the image: ${exception.message}")
@@ -0,0 +1,26 @@
package expo.modules.image
import android.content.Context
import android.util.Log
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.AppGlideModule
/**
* We need to include an [AppGlideModule] for [GlideModule] annotations
* to work.
*/
@GlideModule
class ExpoImageAppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
builder.setLogLevel(
if (BuildConfig.ALLOW_GLIDE_LOGS) {
Log.VERBOSE
} else {
Log.ERROR
}
)
}
}
@@ -0,0 +1,22 @@
package expo.modules.image
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import expo.modules.image.blurhash.BlurhashDecoder
/**
* Clears the Blurhash cache when the memory is low.
*/
object ExpoImageComponentCallbacks : ComponentCallbacks2 {
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() {
BlurhashDecoder.clearCache()
}
override fun onTrimMemory(level: Int) {
if (level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
onLowMemory()
}
}
}
@@ -0,0 +1,341 @@
@file:OptIn(EitherType::class)
package expo.modules.image
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.Base64
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.core.view.doOnDetach
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.Headers
import com.bumptech.glide.load.model.LazyHeaders
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.github.penfeizhou.animation.apng.APNGDrawable
import com.github.penfeizhou.animation.gif.GifDrawable
import com.github.penfeizhou.animation.webp.WebPDrawable
import expo.modules.image.blurhash.BlurhashEncoder
import expo.modules.image.enums.ContentFit
import expo.modules.image.enums.Priority
import expo.modules.image.records.CachePolicy
import expo.modules.image.records.ContentPosition
import expo.modules.image.records.DecodeFormat
import expo.modules.image.records.DecodedSource
import expo.modules.image.records.ImageLoadOptions
import expo.modules.image.records.ImageTransition
import expo.modules.image.records.SourceMap
import expo.modules.image.thumbhash.ThumbhashEncoder
import expo.modules.kotlin.Promise
import expo.modules.kotlin.apifeatures.EitherType
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.functions.Queues
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.sharedobjects.SharedRef
import expo.modules.kotlin.types.Either
import expo.modules.kotlin.types.EitherOfThree
import expo.modules.kotlin.types.toKClass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
class ExpoImageModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoImage")
OnCreate {
appContext.reactContext?.registerComponentCallbacks(ExpoImageComponentCallbacks)
}
OnDestroy {
appContext.reactContext?.unregisterComponentCallbacks(ExpoImageComponentCallbacks)
}
AsyncFunction("prefetch") { urls: List<String>, cachePolicy: CachePolicy, headersMap: Map<String, String>?, promise: Promise ->
val context = appContext.reactContext ?: return@AsyncFunction false
var imagesLoaded = 0
var failed = false
val headers = headersMap?.let {
LazyHeaders.Builder().apply {
it.forEach { (key, value) ->
addHeader(key, value)
}
}.build()
} ?: Headers.DEFAULT
urls.forEach {
Glide
.with(context)
.load(GlideUrl(it, headers)) // Use `load` instead of `download` to store the asset in the memory cache
// We added `quality` and `downsample` to create the same cache key as in final image load.
.encodeQuality(100)
.downsample(NoopDownsampleStrategy)
.customize(`when` = cachePolicy == CachePolicy.MEMORY) {
diskCacheStrategy(DiskCacheStrategy.NONE)
}
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
if (!failed) {
failed = true
promise.resolve(false)
}
return true
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imagesLoaded++
if (imagesLoaded == urls.size) {
promise.resolve(true)
}
return true
}
})
.submit()
}
}
AsyncFunction("loadAsync") Coroutine { source: SourceMap, options: ImageLoadOptions? ->
ImageLoadTask(appContext, source, options ?: ImageLoadOptions()).load()
}
suspend fun generatePlaceholder(
source: Either<URL, Image>,
encoder: (Bitmap) -> String
): String {
val image = source.let {
if (it.`is`(Image::class)) {
it.get(Image::class)
} else {
ImageLoadTask(appContext, SourceMap(uri = it.get(URL::class).toString()), ImageLoadOptions()).load()
}
}
return withContext(Dispatchers.Default) {
encoder(image.ref.toBitmap())
}
}
AsyncFunction("generateBlurhashAsync") Coroutine { source: Either<URL, Image>, numberOfComponents: Pair<Int, Int> ->
generatePlaceholder(source) { bitmap ->
BlurhashEncoder.encode(bitmap, numberOfComponents)
}
}
AsyncFunction("generateThumbhashAsync") Coroutine { source: Either<URL, Image> ->
generatePlaceholder(source) { bitmap ->
Base64.encodeToString(
ThumbhashEncoder.encode(bitmap),
Base64.NO_WRAP
)
}
}
Class(Image::class) {
Property("width") { image: Image ->
image.ref.intrinsicWidth
}
Property("height") { image: Image ->
image.ref.intrinsicHeight
}
Property("scale") { image: Image ->
// Not relying on `2x` in the filename, but want to make the following true:
// If you multiply the logical size of the image by this value, you get the dimensions of the image in pixels.
val screenDensity = appContext.reactContext?.resources?.displayMetrics?.density ?: 1f
(image.ref.toBitmapOrNull()?.density ?: 1) / (screenDensity * 160.0f)
}
Property("isAnimated") { image: Image ->
if (image.ref is GifDrawable) {
return@Property true
}
if (image.ref is APNGDrawable) {
return@Property true
}
if (image.ref is WebPDrawable) {
return@Property true
}
false
}
Property<Any?>("mediaType") { ->
null // not easily supported on Android https://github.com/bumptech/glide/issues/1378#issuecomment-236879983
}
}
AsyncFunction("clearMemoryCache") {
val activity = appContext.currentActivity ?: return@AsyncFunction false
Glide.get(activity).clearMemory()
return@AsyncFunction true
}.runOnQueue(Queues.MAIN)
AsyncFunction<Boolean>("clearDiskCache") {
val activity = appContext.currentActivity ?: return@AsyncFunction false
activity.let {
Glide.get(activity).clearDiskCache()
}
return@AsyncFunction true
}
AsyncFunction("getCachePathAsync") { cacheKey: String ->
val context = appContext.reactContext ?: return@AsyncFunction null
val glideUrl = GlideUrl(cacheKey)
val target = Glide.with(context).asFile().load(glideUrl).onlyRetrieveFromCache(true).submit()
return@AsyncFunction try {
val file = target.get()
file.absolutePath
} catch (_: Exception) {
null
}
}
View(ExpoImageViewWrapper::class) {
Events(
"onLoadStart",
"onProgress",
"onError",
"onLoad",
"onDisplay"
)
Prop("source") { view: ExpoImageViewWrapper, sources: EitherOfThree<List<SourceMap>, SharedRef<Drawable>, SharedRef<Bitmap>>? ->
if (sources == null) {
view.sources = emptyList()
return@Prop
}
if (sources.`is`(toKClass<List<SourceMap>>())) {
view.sources = sources.get(toKClass<List<SourceMap>>())
return@Prop
}
if (sources.`is`(toKClass<SharedRef<Drawable>>())) {
val drawable = sources.get(toKClass<SharedRef<Drawable>>()).ref
view.sources = listOf(DecodedSource(drawable))
return@Prop
}
val bitmap = sources.get(toKClass<SharedRef<Bitmap>>()).ref
val context = appContext.reactContext ?: throw Exceptions.ReactContextLost()
val drawable = BitmapDrawable(context.resources, bitmap)
view.sources = listOf(DecodedSource(drawable))
}
Prop("contentFit") { view: ExpoImageViewWrapper, contentFit: ContentFit? ->
view.contentFit = contentFit ?: ContentFit.Cover
}
Prop("placeholderContentFit") { view: ExpoImageViewWrapper, placeholderContentFit: ContentFit? ->
view.placeholderContentFit = placeholderContentFit ?: ContentFit.ScaleDown
}
Prop("contentPosition") { view: ExpoImageViewWrapper, contentPosition: ContentPosition? ->
view.contentPosition = contentPosition ?: ContentPosition.center
}
Prop("blurRadius") { view: ExpoImageViewWrapper, blurRadius: Int? ->
view.blurRadius = blurRadius?.takeIf { it > 0 }
}
Prop("transition") { view: ExpoImageViewWrapper, transition: ImageTransition? ->
view.transition = transition
}
Prop("tintColor") { view: ExpoImageViewWrapper, color: Int? ->
view.tintColor = color
}
Prop("placeholder") { view: ExpoImageViewWrapper, placeholder: List<SourceMap>? ->
view.placeholders = placeholder ?: emptyList()
}
Prop("accessible") { view: ExpoImageViewWrapper, accessible: Boolean? ->
view.accessible = accessible == true
}
Prop("accessibilityLabel") { view: ExpoImageViewWrapper, accessibilityLabel: String? ->
view.accessibilityLabel = accessibilityLabel
}
Prop("focusable") { view: ExpoImageViewWrapper, isFocusable: Boolean? ->
view.isFocusableProp = isFocusable == true
}
Prop("priority") { view: ExpoImageViewWrapper, priority: Priority? ->
view.priority = priority ?: Priority.NORMAL
}
Prop("cachePolicy") { view: ExpoImageViewWrapper, cachePolicy: CachePolicy? ->
view.cachePolicy = cachePolicy ?: CachePolicy.DISK
}
Prop("recyclingKey") { view: ExpoImageViewWrapper, recyclingKey: String? ->
view.recyclingKey = recyclingKey
}
Prop("allowDownscaling") { view: ExpoImageViewWrapper, allowDownscaling: Boolean? ->
view.allowDownscaling = allowDownscaling != false
}
Prop("autoplay") { view: ExpoImageViewWrapper, autoplay: Boolean? ->
view.autoplay = autoplay != false
}
Prop("decodeFormat") { view: ExpoImageViewWrapper, format: DecodeFormat? ->
view.decodeFormat = format ?: DecodeFormat.ARGB_8888
}
AsyncFunction("startAnimating") { view: ExpoImageViewWrapper ->
view.setIsAnimating(true)
}
AsyncFunction("stopAnimating") { view: ExpoImageViewWrapper ->
view.setIsAnimating(false)
}
AsyncFunction("lockResourceAsync") { view: ExpoImageViewWrapper ->
view.lockResource = true
}
AsyncFunction("unlockResourceAsync") { view: ExpoImageViewWrapper ->
view.lockResource = false
}
AsyncFunction("reloadAsync") { view: ExpoImageViewWrapper ->
view.shouldRerender = true
view.rerenderIfNeeded(force = true)
}
OnViewDidUpdateProps { view: ExpoImageViewWrapper ->
view.rerenderIfNeeded()
}
OnViewDestroys { view: ExpoImageViewWrapper ->
view.doOnDetach {
view.onViewDestroys()
}
}
}
}
}
@@ -0,0 +1,129 @@
package expo.modules.image
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.PorterDuff
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.graphics.transform
import androidx.core.view.isVisible
import com.facebook.react.common.annotations.UnstableReactNativeAPI
import expo.modules.image.enums.ContentFit
import expo.modules.image.records.ContentPosition
@OptIn(UnstableReactNativeAPI::class)
@SuppressLint("ViewConstructor")
class ExpoImageView(
context: Context
) : AppCompatImageView(context) {
var currentTarget: ImageViewWrapperTarget? = null
var isPlaceholder: Boolean = false
fun recycleView(): ImageViewWrapperTarget? {
setImageDrawable(null)
val target = currentTarget?.apply {
isUsed = false
}
currentTarget = null
isVisible = false
isPlaceholder = false
return target
}
private var transformationMatrixChanged = false
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
applyTransformationMatrix()
}
fun applyTransformationMatrix() {
if (drawable == null) {
return
}
if (isPlaceholder) {
applyTransformationMatrix(
drawable,
placeholderContentFit,
sourceHeight = currentTarget?.placeholderHeight,
sourceWidth = currentTarget?.placeholderWidth
)
} else {
applyTransformationMatrix(drawable, contentFit, contentPosition)
}
}
private fun applyTransformationMatrix(
drawable: Drawable,
contentFit: ContentFit,
contentPosition: ContentPosition = ContentPosition.center,
sourceWidth: Int? = currentTarget?.sourceWidth,
sourceHeight: Int? = currentTarget?.sourceHeight
) {
val imageRect = RectF(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
val viewRect = RectF(0f, 0f, width.toFloat(), height.toFloat())
val matrix = contentFit.toMatrix(
imageRect,
viewRect,
sourceWidth ?: -1,
sourceHeight ?: -1
)
val scaledImageRect = imageRect.transform(matrix)
imageMatrix = matrix.apply {
contentPosition.apply(this, scaledImageRect, viewRect)
}
}
init {
clipToOutline = true
scaleType = ScaleType.MATRIX
}
// region Component Props
internal var contentFit: ContentFit = ContentFit.Cover
set(value) {
field = value
transformationMatrixChanged = true
}
internal var placeholderContentFit: ContentFit = ContentFit.ScaleDown
set(value) {
field = value
transformationMatrixChanged = true
}
internal var contentPosition: ContentPosition = ContentPosition.center
set(value) {
field = value
transformationMatrixChanged = true
}
internal fun setTintColor(color: Int?) {
color?.let { setColorFilter(it, PorterDuff.Mode.SRC_IN) } ?: clearColorFilter()
}
override fun draw(canvas: Canvas) {
// If we encounter a recycled bitmap here, it suggests an issue where we may have failed to
// finish clearing the image bitmap before the UI attempts to display it.
// One solution could be to suppress the error and assume that the second image view is currently responsible for displaying the correct view.
if ((drawable as? BitmapDrawable)?.bitmap?.isRecycled == true) {
Log.e("ExpoImage", "Trying to use a recycled bitmap")
recycleView()?.let { target ->
(parent as? ExpoImageViewWrapper)?.requestManager?.let { requestManager ->
target.clear(requestManager)
}
}
}
super.draw(canvas)
}
}
@@ -0,0 +1,647 @@
package expo.modules.image
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Handler
import android.view.View
import android.widget.FrameLayout
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.RequestOptions
import com.github.penfeizhou.animation.gif.GifDrawable
import expo.modules.image.enums.ContentFit
import expo.modules.image.enums.Priority
import expo.modules.image.events.GlideRequestListener
import expo.modules.image.events.OkHttpProgressListener
import expo.modules.image.okhttp.GlideUrlWrapper
import expo.modules.image.records.CachePolicy
import expo.modules.image.records.ContentPosition
import expo.modules.image.records.DecodeFormat
import expo.modules.image.records.ImageErrorEvent
import expo.modules.image.records.ImageLoadEvent
import expo.modules.image.records.ImageProgressEvent
import expo.modules.image.records.ImageTransition
import expo.modules.image.records.Source
import expo.modules.image.svg.SVGPictureDrawable
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.tracing.beginAsyncTraceBlock
import expo.modules.kotlin.tracing.trace
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import jp.wasabeef.glide.transformations.BlurTransformation
import java.lang.ref.WeakReference
import kotlin.math.abs
import kotlin.math.min
@SuppressLint("ViewConstructor")
class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
private val activity: Activity
get() = appContext.throwingActivity
internal val requestManager = getOrCreateRequestManager(appContext, activity)
private val progressListener = OkHttpProgressListener(WeakReference(this))
private val firstView = ExpoImageView(activity)
private val secondView = ExpoImageView(activity)
private val mainHandler = Handler(context.mainLooper)
/**
* @returns the view which is currently active or will be used when both views are empty
*/
private val activeView: ExpoImageView
get() {
if (secondView.drawable != null) {
return secondView
}
return firstView
}
private var firstTarget = ImageViewWrapperTarget(WeakReference(this))
private var secondTarget = ImageViewWrapperTarget(WeakReference(this))
internal val onLoadStart by EventDispatcher<Unit>()
internal val onProgress by EventDispatcher<ImageProgressEvent>()
internal val onError by EventDispatcher<ImageErrorEvent>()
internal val onLoad by EventDispatcher<ImageLoadEvent>()
internal val onDisplay by EventDispatcher<Unit>()
internal var sources: List<Source> = emptyList()
private val bestSource: Source?
get() = getBestSource(sources)
internal var placeholders: List<Source> = emptyList()
private val bestPlaceholder: Source?
get() = getBestSource(placeholders)
internal var blurRadius: Int? = null
set(value) {
if (field != value) {
shouldRerender = true
}
field = value
}
internal var transition: ImageTransition? = null
internal var contentFit: ContentFit = ContentFit.Cover
set(value) {
field = value
activeView.contentFit = value
transformationMatrixChanged = true
}
internal var placeholderContentFit: ContentFit = ContentFit.ScaleDown
set(value) {
field = value
activeView.placeholderContentFit = value
transformationMatrixChanged = true
}
internal var contentPosition: ContentPosition = ContentPosition.center
set(value) {
field = value
activeView.contentPosition = value
transformationMatrixChanged = true
}
internal var tintColor: Int? = null
set(value) {
field = value
// To apply the tint color to the SVG, we need to recreate the drawable.
if (activeView.drawable is SVGPictureDrawable) {
shouldRerender = true
} else {
activeView.setTintColor(value)
}
}
internal var isFocusableProp: Boolean = false
set(value) {
field = value
activeView.isFocusable = value
}
internal var accessible: Boolean = false
set(value) {
field = value
setIsScreenReaderFocusable(activeView, value)
}
internal var accessibilityLabel: String? = null
set(value) {
field = value
activeView.contentDescription = accessibilityLabel
}
var recyclingKey: String? = null
set(value) {
clearViewBeforeChangingSource = field != null && value != null && value != field
field = value
}
internal var allowDownscaling: Boolean = true
set(value) {
field = value
shouldRerender = true
}
internal var decodeFormat: DecodeFormat = DecodeFormat.ARGB_8888
set(value) {
field = value
shouldRerender = true
}
internal var autoplay: Boolean = true
internal var lockResource: Boolean = false
internal var priority: Priority = Priority.NORMAL
internal var cachePolicy: CachePolicy = CachePolicy.DISK
fun setIsAnimating(setAnimating: Boolean) {
// Animatable animations always start from the beginning when resumed.
// So we check first if the resource is a GifDrawable, because it can continue
// from where it was paused.
when (val resource = activeView.drawable) {
is GifDrawable -> setIsAnimating(resource, setAnimating)
is Animatable -> setIsAnimating(resource, setAnimating)
}
}
private fun setIsAnimating(resource: GifDrawable, setAnimating: Boolean) {
if (setAnimating) {
if (resource.isPaused) {
resource.resume()
} else {
resource.start()
}
} else {
resource.pause()
}
}
private fun setIsAnimating(resource: Animatable, setAnimating: Boolean) {
if (setAnimating) {
resource.start()
} else {
resource.stop()
}
}
/**
* Whether the image should be loaded again
*/
internal var shouldRerender = false
/**
* Currently loaded source
*/
private var loadedSource: GlideModelProvider? = null
/**
* Whether the transformation matrix should be reapplied
*/
private var transformationMatrixChanged = false
/**
* Whether the view content should be cleared to blank when the source was changed.
*/
private var clearViewBeforeChangingSource = false
/**
* Copies saved props to the provided view.
* It ensures that the view state is up to date.
*/
private fun copyProps(view: ExpoImageView) {
view.contentFit = contentFit
view.contentPosition = contentPosition
view.setTintColor(tintColor)
view.isFocusable = isFocusableProp
view.contentDescription = accessibilityLabel
setIsScreenReaderFocusable(view, accessible)
}
/**
* Allows `isScreenReaderFocusable` to be set on apis below level 28
*/
private fun setIsScreenReaderFocusable(view: View, value: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
view.isScreenReaderFocusable = value
} else {
ViewCompat.setAccessibilityDelegate(
this,
object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
info.isScreenReaderFocusable = value
super.onInitializeAccessibilityNodeInfo(host, info)
}
}
)
}
}
/**
* When a new resource is available, this method tries to handle it.
* It decides where provided bitmap should be displayed and clears the previous target/image.
*/
fun onResourceReady(
target: ImageViewWrapperTarget,
resource: Drawable,
isPlaceholder: Boolean = false
) =
// The "onResourceReady" function will be triggered when the new resource is available by the Glide.
// According to the Glide documentation (https://bumptech.github.io/glide/doc/debugging.html#you-cant-start-or-clear-loads-in-requestlistener-or-target-callbacks),
// it's not advisable to clear the Glide target within the stack frame.
// To avoid this, a new runnable is posted to the front of the main queue, which can then clean or create targets.
// This ensures that the "onResourceReady" frame of the Glide code will be discarded, and the internal state can be altered once again.
// Normally, using "postAtFrontOfQueue" can lead to issues such as message queue starvation, ordering problems, and other unexpected consequences.
// However, in this case, it is safe to use as long as nothing else is added to the queue.
// The intention is simply to wait for the Glide code to finish before the content of the underlying views is changed during the same rendering tick.
mainHandler.postAtFrontOfQueue {
trace(Trace.tag, "onResourceReady") {
val transitionDuration = (transition?.duration ?: 0).toLong()
// If provided resource is a placeholder, but the target doesn't have a source, we treat it as a normal image.
if (!isPlaceholder || !target.hasSource) {
val (newView, previousView) = if (firstView.drawable == null) {
firstView to secondView
} else {
secondView to firstView
}
val clearPreviousView = {
previousView
.recycleView()
?.apply {
// When the placeholder is loaded, one target is displayed in both views.
// So we just have to move the reference to a new view instead of clearing the target.
if (this != target) {
clear(requestManager)
}
}
}
configureView(newView, target, resource, isPlaceholder)
// Dispatch "onDisplay" event only for the main source (no placeholder).
if (target.hasSource) {
onDisplay.invoke(Unit)
}
if (transitionDuration <= 0) {
clearPreviousView()
newView.alpha = 1f
newView.bringToFront()
} else {
newView.bringToFront()
previousView.alpha = 1f
newView.alpha = 0f
previousView.animate().apply {
duration = transitionDuration
alpha(0f)
withEndAction {
clearPreviousView()
}
}
newView.animate().apply {
duration = transitionDuration
alpha(1f)
}
}
} else {
// We don't want to show the placeholder if something is currently displayed.
// There is one exception - when we're displaying a different placeholder.
if ((firstView.drawable != null && !firstView.isPlaceholder) || secondView.drawable != null) {
return@trace
}
firstView
.recycleView()
?.apply {
// The current target is already bound to the view. We don't want to cancel it in that case.
if (this != target) {
clear(requestManager)
}
}
configureView(firstView, target, resource, isPlaceholder)
if (transitionDuration > 0) {
firstView.bringToFront()
firstView.alpha = 0f
secondView.isVisible = false
firstView.animate().apply {
duration = transitionDuration
alpha(1f)
}
}
}
// If our image is animated, we want to see if autoplay is disabled. If it is, we should
// stop the animation as soon as the resource is ready. Placeholders should not follow this
// value since the intention is almost certainly to display the animation (i.e. a spinner)
if (resource is Animatable && !isPlaceholder && !autoplay) {
resource.stop()
}
}
}
private fun configureView(
view: ExpoImageView,
target: ImageViewWrapperTarget,
resource: Drawable,
isPlaceholder: Boolean
) {
view.let {
it.setImageDrawable(resource)
it.isPlaceholder = isPlaceholder
it.placeholderContentFit = target.placeholderContentFit ?: ContentFit.ScaleDown
copyProps(it)
it.isVisible = true
it.currentTarget = target
// The view isn't layout when it's invisible.
// Therefore, we have to set the correct size manually.
it.layout(0, 0, width, height)
it.applyTransformationMatrix()
}
target.isUsed = true
if (resource is Animatable) {
resource.start()
}
}
private fun getBestSource(sources: List<Source>): Source? {
if (sources.isEmpty()) {
return null
}
if (sources.size == 1) {
return sources.first()
}
val targetPixelCount = width * height
if (targetPixelCount == 0) {
return null
}
var bestSource: Source? = null
var bestFit = Double.MAX_VALUE
sources.forEach {
val fit = abs(1 - (it.pixelCount / targetPixelCount))
if (fit < bestFit) {
bestFit = fit
bestSource = it
}
}
return bestSource
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
rerenderIfNeeded(
shouldRerenderBecauseOfResize = allowDownscaling &&
contentFit != ContentFit.Fill &&
contentFit != ContentFit.None
)
}
private fun createPropOptions(): RequestOptions {
return RequestOptions()
.priority(this@ExpoImageViewWrapper.priority.toGlidePriority())
.customize(`when` = cachePolicy != CachePolicy.MEMORY_AND_DISK && cachePolicy != CachePolicy.MEMORY) {
skipMemoryCache(true)
}
.customize(`when` = cachePolicy == CachePolicy.NONE || cachePolicy == CachePolicy.MEMORY) {
diskCacheStrategy(DiskCacheStrategy.NONE)
}
.customize(blurRadius) {
transform(BlurTransformation(min(it, 25), 4))
}
}
fun onViewDestroys() {
firstView.setImageDrawable(null)
secondView.setImageDrawable(null)
requestManager.clear(firstTarget)
requestManager.clear(secondTarget)
}
private fun cleanIfNeeded(
newBestSource: Source?,
newBestSourceModel: GlideModelProvider?,
newBestPlaceholderModel: GlideModelProvider?
): Boolean {
// We only clean the image when the source is set to null and we don't have a placeholder or the view is empty.
if (width == 0 || height == 0 || (newBestSource == null || newBestSourceModel == null) && newBestPlaceholderModel == null) {
firstView.recycleView()
secondView.recycleView()
requestManager.clear(firstTarget)
requestManager.clear(secondTarget)
shouldRerender = false
loadedSource = null
transformationMatrixChanged = false
clearViewBeforeChangingSource = false
return true
}
return false
}
private fun createDownsampleStrategy(target: ImageViewWrapperTarget): DownsampleStrategy {
return if (!allowDownscaling) {
DownsampleStrategy.NONE
} else if (
contentFit != ContentFit.Fill &&
contentFit != ContentFit.None
) {
ContentFitDownsampleStrategy(target, contentFit)
} else {
// it won't downscale the image if the image is smaller than hardware bitmap size limit
SafeDownsampleStrategy(decodeFormat)
}
}
private fun clearViewBeforeChangingSource() {
if (clearViewBeforeChangingSource) {
val activeView = if (firstView.drawable != null) {
firstView
} else {
secondView
}
activeView
.recycleView()
?.apply {
clear(requestManager)
}
}
}
internal fun rerenderIfNeeded(shouldRerenderBecauseOfResize: Boolean = false, force: Boolean = false) =
trace(Trace.tag, "rerenderIfNeeded(shouldRerenderBecauseOfResize=$shouldRerenderBecauseOfResize,force=$force)") {
if (lockResource && !force) {
return@trace
}
val bestSource = bestSource
val bestPlaceholder = bestPlaceholder
val sourceToLoad = bestSource?.createGlideModelProvider(context)
val placeholder = bestPlaceholder?.createGlideModelProvider(context)
if (cleanIfNeeded(bestSource, sourceToLoad, placeholder)) {
// the view was cleaned
return@trace
}
val shouldRerender = sourceToLoad != loadedSource || shouldRerender || (sourceToLoad == null && placeholder != null)
if (!shouldRerender && !shouldRerenderBecauseOfResize) {
// In the case where the source didn't change, but the transformation matrix has to be
// recalculated, we can apply the new transformation right away.
// When the source and the matrix is different, we don't want to do anything.
// We don't want to changed the transformation of the currently displayed image.
// The new matrix will be applied when new resource is loaded.
if (transformationMatrixChanged) {
activeView.applyTransformationMatrix()
}
transformationMatrixChanged = false
clearViewBeforeChangingSource = false
return@trace
}
clearViewBeforeChangingSource()
this.shouldRerender = false
loadedSource = sourceToLoad
val options = bestSource?.createGlideOptions(context)
val propOptions = createPropOptions()
val model = sourceToLoad?.getGlideModel()
if (model is GlideUrlWrapper) {
model.progressListener = progressListener
}
onLoadStart.invoke(Unit)
val newTarget = if (secondTarget.isUsed) {
firstTarget
} else {
secondTarget
}
newTarget.hasSource = sourceToLoad != null
val downsampleStrategy = createDownsampleStrategy(newTarget)
val request = requestManager
.asDrawable()
.load(model)
.customize(bestPlaceholder, placeholder) { placeholderSource, placeholderModel ->
val newPlaceholderContentFit = if (!placeholderSource.usesPlaceholderContentFit()) {
contentFit
} else {
placeholderContentFit
}
newTarget.placeholderContentFit = newPlaceholderContentFit
thumbnail(
requestManager.load(placeholderModel.getGlideModel())
.downsample(PlaceholderDownsampleStrategy(newTarget))
.apply(placeholderSource.createGlideOptions(context))
)
}
.downsample(downsampleStrategy)
.addListener(GlideRequestListener(WeakReference(this)))
.encodeQuality(100)
.format(decodeFormat.toGlideFormat())
.apply(propOptions)
.apply(options)
.customize(tintColor) {
apply(RequestOptions().set(CustomOptions.tintColor, it))
}
val cookie = Trace.getNextCookieValue()
beginAsyncTraceBlock(Trace.tag, Trace.loadNewImageBlock, cookie)
newTarget.setCookie(cookie)
request.into(newTarget)
transformationMatrixChanged = false
clearViewBeforeChangingSource = false
}
init {
val matchParent = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
layoutParams = matchParent
firstView.isVisible = true
secondView.isVisible = true
// We need to add a `FrameLayout` to allow views to overflow.
// With the `LinearLayout` is impossible to render two views on each other.
val layout = FrameLayout(context).apply {
layoutParams = matchParent
addView(
firstView,
matchParent
)
addView(
secondView,
matchParent
)
}
addView(layout, matchParent)
}
companion object {
private var requestManager: RequestManager? = null
private var appContextRef: WeakReference<AppContext?> = WeakReference(null)
private var activityRef: WeakReference<Activity?> = WeakReference(null)
fun getOrCreateRequestManager(
appContext: AppContext,
activity: Activity
): RequestManager = synchronized(Companion) {
val cachedRequestManager = requestManager
?: return createNewRequestManager(activity).also {
requestManager = it
appContextRef = WeakReference(appContext)
activityRef = WeakReference(activity)
}
// Request manager was created using different activity or app context
if (appContextRef.get() != appContext || activityRef.get() != activity) {
return createNewRequestManager(activity).also {
requestManager = it
appContextRef = WeakReference(appContext)
activityRef = WeakReference(activity)
}
}
return cachedRequestManager
}
private fun createNewRequestManager(activity: Activity): RequestManager = Glide.with(activity)
}
}
@@ -0,0 +1,67 @@
package expo.modules.image
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.RequestOptions
/**
* Conditionally applies the block to the RequestBuilder if the condition is true.
*/
fun <T> RequestBuilder<T>.customize(`when`: Boolean, block: RequestBuilder<T>.() -> RequestBuilder<T>): RequestBuilder<T> {
if (!`when`) {
return this
}
return block()
}
/**
* Conditionally applies the block to the RequestBuilder if the value is not null.
*/
inline fun <T, P> RequestBuilder<T>.customize(value: P?, block: RequestBuilder<T>.(P) -> RequestBuilder<T>): RequestBuilder<T> {
if (value == null) {
return this
}
return block(value)
}
/**
* Conditionally applies the block to the RequestBuilder if both values aren't null.
*/
inline fun <T, P1, P2> RequestBuilder<T>.customize(first: P1?, second: P2?, block: RequestBuilder<T>.(P1, P2) -> RequestBuilder<T>): RequestBuilder<T> {
if (first == null || second == null) {
return this
}
return block(first, second)
}
/**
* Conditionally applies the block to the RequestOptions if the condition is true.
*/
inline fun RequestOptions.customize(`when`: Boolean, block: RequestOptions.() -> RequestOptions): RequestOptions {
if (!`when`) {
return this
}
return block()
}
/**
* Conditionally applies the block to the RequestOptions if the value is not null.
*/
inline fun <T> RequestOptions.customize(value: T?, block: RequestOptions.(T) -> RequestOptions): RequestOptions {
if (value == null) {
return this
}
return block(value)
}
fun <T> RequestBuilder<T>.apply(options: RequestOptions?): RequestBuilder<T> {
if (options == null) {
return this
}
return apply(options)
}
@@ -0,0 +1,51 @@
package expo.modules.image
import android.graphics.drawable.Drawable
import android.net.Uri
import com.bumptech.glide.load.model.GlideUrl
import expo.modules.image.blurhash.BlurhashModel
import expo.modules.image.decodedsource.DecodedModel
import expo.modules.image.okhttp.GlideUrlWrapper
import expo.modules.image.thumbhash.ThumbhashModel
fun interface GlideModelProvider {
fun getGlideModel(): Any
}
data class DecodedModelProvider(
private val drawable: Drawable
) : GlideModelProvider {
override fun getGlideModel() = DecodedModel(drawable)
}
data class UrlModelProvider(
private val glideUrl: GlideUrl
) : GlideModelProvider {
override fun getGlideModel() = GlideUrlWrapper(glideUrl)
}
data class RawModelProvider(
private val data: String
) : GlideModelProvider {
override fun getGlideModel() = data
}
data class UriModelProvider(
private val uri: Uri
) : GlideModelProvider {
override fun getGlideModel() = uri
}
data class BlurhashModelProvider(
private val uri: Uri,
private val width: Int,
private val height: Int
) : GlideModelProvider {
override fun getGlideModel() = BlurhashModel(uri, width, height)
}
data class ThumbhashModelProvider(
private val uri: Uri
) : GlideModelProvider {
override fun getGlideModel() = ThumbhashModel(uri)
}
@@ -0,0 +1,20 @@
package expo.modules.image
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import expo.modules.kotlin.sharedobjects.SharedRef
class Image(ref: Drawable) : SharedRef<Drawable>(ref) {
override val nativeRefType: String = "image"
override fun getAdditionalMemoryPressure(): Int {
val ref = ref
if (ref is BitmapDrawable) {
return ref.getBitmap().allocationByteCount
}
// We can't get the size in bytes of the drawable.
// Let's just return the size in pixels for now.
return ref.intrinsicWidth * ref.intrinsicHeight
}
}
@@ -0,0 +1,39 @@
package expo.modules.image
import com.bumptech.glide.Glide
import expo.modules.image.records.ImageLoadOptions
import expo.modules.image.records.SourceMap
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.exception.Exceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
open class ImageLoadTask(
private val appContext: AppContext,
private val source: SourceMap,
private val options: ImageLoadOptions
) {
suspend fun load(): Image {
val context =
this@ImageLoadTask.appContext.reactContext ?: throw Exceptions.ReactContextLost()
val sourceToLoad = source.createGlideModelProvider(context)
val model = sourceToLoad?.getGlideModel()
try {
val bitmap = withContext(Dispatchers.IO) {
Glide
.with(context)
.asDrawable()
.load(model)
.centerInside()
.submit(options.maxWidth, options.maxHeight)
.get()
}
return Image(bitmap)
} catch (e: Exception) {
throw ImageLoadFailed(e)
}
}
}
@@ -0,0 +1,42 @@
package expo.modules.image
import android.graphics.RectF
fun calcXTranslation(
value: Float,
imageRect: RectF,
viewRect: RectF,
isPercentage: Boolean = false,
isReverse: Boolean = false
): Float = calcTranslation(value, imageRect.width(), viewRect.width(), isPercentage, isReverse)
fun calcYTranslation(
value: Float,
imageRect: RectF,
viewRect: RectF,
isPercentage: Boolean = false,
isReverse: Boolean = false
): Float = calcTranslation(value, imageRect.height(), viewRect.height(), isPercentage, isReverse)
fun calcTranslation(
value: Float,
imageRefValue: Float,
viewRefValue: Float,
isPercentage: Boolean = false,
isReverse: Boolean = false
): Float {
if (isPercentage) {
val finalPercentage = if (isReverse) {
100f - value
} else {
value
}
return (finalPercentage / 100f) * (viewRefValue - imageRefValue)
}
if (isReverse) {
return viewRefValue - imageRefValue - value
}
return value
}
@@ -0,0 +1,350 @@
package expo.modules.image
import android.content.Context
import android.graphics.Point
import android.graphics.drawable.Drawable
import android.util.Log
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.annotation.VisibleForTesting
import com.bumptech.glide.RequestManager
import com.bumptech.glide.request.Request
import com.bumptech.glide.request.ThumbnailRequestCoordinator
import com.bumptech.glide.request.target.SizeReadyCallback
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.Transition
import com.bumptech.glide.util.Preconditions
import com.bumptech.glide.util.Synthetic
import expo.modules.core.utilities.ifNull
import expo.modules.image.enums.ContentFit
import expo.modules.kotlin.tracing.endAsyncTraceBlock
import java.lang.ref.WeakReference
import kotlin.math.max
/**
* A custom target to provide a smooth transition between multiple drawables.
* It delegates images to the [ExpoImageViewWrapper], where we handle the loaded [Drawable].
* When the target is cleared, we don't do anything. The [ExpoImageViewWrapper] is responsible for
* clearing bitmaps before freeing targets. That may be error-prone, but that is the only way
* of implementing the transition between bitmaps.
*/
class ImageViewWrapperTarget(
private val imageViewHolder: WeakReference<ExpoImageViewWrapper>
) : Target<Drawable> {
/**
* Whether the target has a main, non-placeholder source
*/
var hasSource = false
/**
* Whether the target is used - the asset loaded by it has been drawn in the image view
*/
var isUsed = false
/**
* The main source height where -1 means unknown
*/
var sourceHeight = -1
/**
* The main source width where -1 means unknown
*/
var sourceWidth = -1
/**
* The placeholder height where -1 means unknown
*/
var placeholderHeight = -1
/**
* The placeholder width where -1 means unknown
*/
var placeholderWidth = -1
private var cookie = -1
fun setCookie(newValue: Int) {
endLoadingNewImageTraceBlock()
synchronized(this) {
cookie = newValue
}
}
/**
* The content fit of the placeholder
*/
var placeholderContentFit: ContentFit? = null
private var request: Request? = null
private var sizeDeterminer = SizeDeterminer(imageViewHolder)
private fun endLoadingNewImageTraceBlock() = synchronized(this) {
if (cookie < 0) {
return@synchronized
}
endAsyncTraceBlock(Trace.tag, Trace.loadNewImageBlock, cookie)
cookie = -1
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
// The image view should always be valid. When the view is deallocated, all targets should be
// canceled. Therefore that code shouldn't be called in that case. Instead of crashing, we
// decided to ignore that.
val imageView = imageViewHolder.get().ifNull {
endLoadingNewImageTraceBlock()
Log.w("ExpoImage", "The `ExpoImageViewWrapper` was deallocated, but the target wasn't canceled in time.")
return
}
// The thumbnail and full request are handled in the same way by Glide.
// Here we're checking if the provided resource is the final bitmap or a thumbnail.
val isPlaceholder = if (request is ThumbnailRequestCoordinator) {
(request as? ThumbnailRequestCoordinator)
?.getPrivateFullRequest()
?.isComplete == false
} else {
false
}
if (!isPlaceholder) {
endLoadingNewImageTraceBlock()
}
imageView.onResourceReady(this, resource, isPlaceholder)
}
override fun onStart() = Unit
override fun onStop() = Unit
override fun onDestroy() = Unit
override fun onLoadStarted(placeholder: Drawable?) = Unit
// When loading fails, it's handled by the global listener, therefore that method can be NOOP.
override fun onLoadFailed(errorDrawable: Drawable?) {
endLoadingNewImageTraceBlock()
}
override fun onLoadCleared(placeholder: Drawable?) = Unit
override fun getSize(cb: SizeReadyCallback) {
// If we can't resolve the image, we just return unknown size.
// It shouldn't happen in a production application, because it means that our view was deallocated.
if (imageViewHolder.get() == null) {
cb.onSizeReady(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
return
}
sizeDeterminer.getSize(cb)
}
override fun removeCallback(cb: SizeReadyCallback) {
sizeDeterminer.removeCallback(cb)
}
override fun setRequest(request: Request?) {
this.request = request
}
override fun getRequest() = request
fun clear(requestManager: RequestManager) {
sizeDeterminer.clearCallbacksAndListener()
requestManager.clear(this)
}
}
// Copied from the Glide codebase.
// We modified that to receive a weak ref to our view instead of strong one.
internal class SizeDeterminer(private val imageViewHolder: WeakReference<ExpoImageViewWrapper>) {
private val cbs: MutableList<SizeReadyCallback> = ArrayList()
@Synthetic
var waitForLayout = false
private var layoutListener: SizeDeterminerLayoutListener? = null
private fun notifyCbs(width: Int, height: Int) {
// One or more callbacks may trigger the removal of one or more additional callbacks, so we
// need a copy of the list to avoid a concurrent modification exception. One place this
// happens is when a full request completes from the in memory cache while its thumbnail is
// still being loaded asynchronously. See #2237.
for (cb in ArrayList(cbs)) {
cb.onSizeReady(width, height)
}
}
@Synthetic
fun checkCurrentDimens() {
if (cbs.isEmpty()) {
return
}
val currentWidth = targetWidth
val currentHeight = targetHeight
if (!isViewStateAndSizeValid(currentWidth, currentHeight)) {
return
}
notifyCbs(currentWidth, currentHeight)
clearCallbacksAndListener()
}
fun getSize(cb: SizeReadyCallback) {
val view = imageViewHolder.get() ?: return
val currentWidth = targetWidth
val currentHeight = targetHeight
if (isViewStateAndSizeValid(currentWidth, currentHeight)) {
cb.onSizeReady(currentWidth, currentHeight)
return
}
// We want to notify callbacks in the order they were added and we only expect one or two
// callbacks to be added a time, so a List is a reasonable choice.
if (!cbs.contains(cb)) {
cbs.add(cb)
}
if (layoutListener == null) {
val observer = view.viewTreeObserver
layoutListener = SizeDeterminerLayoutListener(this)
observer.addOnPreDrawListener(layoutListener)
}
}
/**
* The callback may be called anyway if it is removed by another [SizeReadyCallback] or
* otherwise removed while we're notifying the list of callbacks.
*
*
* See #2237.
*/
fun removeCallback(cb: SizeReadyCallback) {
cbs.remove(cb)
}
fun clearCallbacksAndListener() {
// Keep a reference to the layout attachStateListener and remove it here
// rather than having the observer remove itself because the observer
// we add the attachStateListener to will be almost immediately merged into
// another observer and will therefore never be alive. If we instead
// keep a reference to the attachStateListener and remove it here, we get the
// current view tree observer and should succeed.
val observer = imageViewHolder.get()?.viewTreeObserver
if (observer?.isAlive == true) {
observer.removeOnPreDrawListener(layoutListener)
}
layoutListener = null
cbs.clear()
}
private fun isViewStateAndSizeValid(width: Int, height: Int): Boolean {
return isDimensionValid(width) && isDimensionValid(height)
}
private val targetHeight: Int
get() {
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
val verticalPadding = view.paddingTop + view.paddingBottom
val layoutParams = view.layoutParams
val layoutParamSize = layoutParams?.height ?: PENDING_SIZE
return getTargetDimen(view.height, layoutParamSize, verticalPadding)
}
private val targetWidth: Int
get() {
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
val horizontalPadding = view.paddingLeft + view.paddingRight
val layoutParams = view.layoutParams
val layoutParamSize = layoutParams?.width ?: PENDING_SIZE
return getTargetDimen(view.width, layoutParamSize, horizontalPadding)
}
private fun getTargetDimen(viewSize: Int, paramSize: Int, paddingSize: Int): Int {
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
// We consider the View state as valid if the View has non-null layout params and a non-zero
// layout params width and height. This is imperfect. We're making an assumption that View
// parents will obey their child's layout parameters, which isn't always the case.
val adjustedParamSize = paramSize - paddingSize
if (adjustedParamSize > 0) {
return adjustedParamSize
}
// Since we always prefer layout parameters with fixed sizes, even if waitForLayout is true,
// we might as well ignore it and just return the layout parameters above if we have them.
// Otherwise we should wait for a layout pass before checking the View's dimensions.
if (waitForLayout && view.isLayoutRequested) {
return PENDING_SIZE
}
// We also consider the View state valid if the View has a non-zero width and height. This
// means that the View has gone through at least one layout pass. It does not mean the Views
// width and height are from the current layout pass. For example, if a View is re-used in
// RecyclerView or ListView, this width/height may be from an old position. In some cases
// the dimensions of the View at the old position may be different than the dimensions of the
// View in the new position because the LayoutManager/ViewParent can arbitrarily decide to
// change them. Nevertheless, in most cases this should be a reasonable choice.
val adjustedViewSize = viewSize - paddingSize
if (adjustedViewSize > 0) {
return adjustedViewSize
}
// Finally we consider the view valid if the layout parameter size is set to wrap_content.
// It's difficult for Glide to figure out what to do here. Although Target.SIZE_ORIGINAL is a
// coherent choice, it's extremely dangerous because original images may be much too large to
// fit in memory or so large that only a couple can fit in memory, causing OOMs. If users want
// the original image, they can always use .override(Target.SIZE_ORIGINAL). Since wrap_content
// may never resolve to a real size unless we load something, we aim for a square whose length
// is the largest screen size. That way we're loading something and that something has some
// hope of being downsampled to a size that the device can support. We also log a warning that
// tries to explain what Glide is doing and why some alternatives are preferable.
// Since WRAP_CONTENT is sometimes used as a default layout parameter, we always wait for
// layout to complete before using this fallback parameter (ConstraintLayout among others).
if (!view.isLayoutRequested && paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
return getMaxDisplayLength(view.context)
}
// If the layout parameters are < padding, the view size is < padding, or the layout
// parameters are set to match_parent or wrap_content and no layout has occurred, we should
// wait for layout and repeat.
return PENDING_SIZE
}
private fun isDimensionValid(size: Int): Boolean {
return size > 0 || size == Target.SIZE_ORIGINAL
}
private class SizeDeterminerLayoutListener(sizeDeterminer: SizeDeterminer) : ViewTreeObserver.OnPreDrawListener {
private val sizeDeterminerRef: WeakReference<SizeDeterminer>
init {
sizeDeterminerRef = WeakReference(sizeDeterminer)
}
override fun onPreDraw(): Boolean {
val sizeDeterminer = sizeDeterminerRef.get()
sizeDeterminer?.checkCurrentDimens()
return true
}
}
companion object {
// Some negative sizes (Target.SIZE_ORIGINAL) are valid, 0 is never valid.
private const val PENDING_SIZE = 0
@VisibleForTesting
var maxDisplayLength: Int? = null
// Use the maximum to avoid depending on the device's current orientation.
@Suppress("DEPRECATION") // We have copied this code from Glide and are waiting for them to remove the deprecated APIs.
private fun getMaxDisplayLength(context: Context): Int {
if (maxDisplayLength == null) {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = Preconditions.checkNotNull(windowManager).defaultDisplay
val displayDimensions = Point()
display.getSize(displayDimensions)
maxDisplayLength = max(displayDimensions.x, displayDimensions.y)
}
return maxDisplayLength!!
}
}
}
@@ -0,0 +1,47 @@
package expo.modules.image
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
import java.util.*
object ResourceIdHelper {
private val idMap = mutableMapOf<String, Int>()
@SuppressLint("DiscouragedApi")
private fun getResourceRawId(context: Context, name: String): Int {
if (name.isEmpty()) {
return -1
}
val normalizedName = name.lowercase(Locale.ROOT).replace("-", "_")
synchronized(this) {
val id = idMap[normalizedName]
if (id != null) {
return id
}
return context
.resources
.getIdentifier(normalizedName, "raw", context.packageName)
.also {
idMap[normalizedName] = it
}
}
}
fun getResourceUri(context: Context, name: String): Uri? {
val drawableUri = ResourceDrawableIdHelper.getResourceDrawableUri(context, name)
if (drawableUri != Uri.EMPTY) {
return drawableUri
}
val resId = getResourceRawId(context, name)
return if (resId > 0) {
Uri.Builder().scheme("res").path(resId.toString()).build()
} else {
null
}
}
}
@@ -0,0 +1,21 @@
package expo.modules.image
import android.util.Log
import com.bumptech.glide.request.Request
import com.bumptech.glide.request.ThumbnailRequestCoordinator
fun ThumbnailRequestCoordinator.getPrivateFullRequest(): Request? {
return getPrivateField("full")
}
private fun <T> ThumbnailRequestCoordinator.getPrivateField(name: String): T? {
return try {
val field = this.javaClass.getDeclaredField(name)
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
field.get(this) as T
} catch (e: Throwable) {
Log.e("ExpoImage", "Couldn't receive the `$name` field", e)
null
}
}
@@ -0,0 +1,11 @@
package expo.modules.image
object Trace {
val tag = "ExpoImage"
val loadNewImageBlock = "load new image"
private var lastCookieValue = 0
fun getNextCookieValue() = synchronized(this) {
lastCookieValue++
}
}
@@ -0,0 +1,20 @@
package expo.modules.image
import com.facebook.yoga.YogaConstants
fun Float.ifYogaUndefinedUse(value: Float) =
if (YogaConstants.isUndefined(this)) {
value
} else {
this
}
inline fun Float.ifYogaDefinedUse(transformFun: (current: Float) -> Float) =
if (YogaConstants.isUndefined(this)) {
this
} else {
transformFun(this)
}
fun makeYogaUndefinedIfNegative(value: Float) =
if (!YogaConstants.isUndefined(value) && value < 0) YogaConstants.UNDEFINED else value
@@ -0,0 +1,32 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
import expo.modules.kotlin.exception.CodedException
class BlurhashDecodingFailure(blurHash: String?) : CodedException(
message = "Cannot decode provided blurhash '$blurHash'"
)
class BlurHashFetcher(
private val blurHash: String?,
private val width: Int,
private val height: Int,
private val punch: Float
) : DataFetcher<Bitmap> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
val bitmap = BlurhashDecoder.decode(blurHash, width, height, punch)
if (bitmap == null) {
callback.onLoadFailed(BlurhashDecodingFailure(blurHash))
return
}
callback.onDataReady(bitmap)
}
}
@@ -0,0 +1,180 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
/**
* Copied from https://github.com/woltapp/blurhash.
*/
object BlurhashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/**
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
}
return result
}
private fun decodeDc(colorEnc: Int): FloatArray {
val r = colorEnc shr 16
val g = (colorEnc shr 8) and 255
val b = colorEnc and 255
return floatArrayOf(BlurhashHelpers.srgbToLinear(r), BlurhashHelpers.srgbToLinear(g), BlurhashHelpers.srgbToLinear(b))
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc
)
}
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
private fun composeBitmap(
width: Int,
height: Int,
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
useCache: Boolean
): Bitmap {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numCompY) {
for (i in 0 until numCompX) {
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
val basis = (cosX * cosY).toFloat()
val color = colors[j * numCompX + i]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
calculate -> {
DoubleArray(height * numCompY).also {
cacheCosinesY[height * numCompY] = it
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
calculate -> {
DoubleArray(width * numCompX).also {
cacheCosinesX[width * numCompX] = it
}
}
else -> cacheCosinesX[width * numCompX]!!
}
private fun DoubleArray.getCos(
calculate: Boolean,
x: Int,
numComp: Int,
y: Int,
size: Int
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
}
return this[x + numComp * y]
}
private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
private val charMap = listOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
)
.mapIndexed { i, c -> c to i }
.toMap()
}
@@ -0,0 +1,112 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.*
/**
* Rewritten in kotlin from https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashEncode.swift
*/
object BlurhashEncoder {
fun encode(image: Bitmap, numberOfComponents: Pair<Int, Int>): String {
val pixels = IntArray(image.width * image.height)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
val factors = calculateBlurFactors(pixels, image.width, image.height, numberOfComponents)
val dc = factors.first()
val ac = factors.drop(1)
val hashBuilder = StringBuilder()
encodeFlag(numberOfComponents, hashBuilder)
val maximumValue = encodeMaximumValue(ac, hashBuilder)
hashBuilder.append(encode83(encodeDC(dc), 4))
for (factor in ac) {
hashBuilder.append(encode83(encodeAC(factor, maximumValue), 2))
}
return hashBuilder.toString()
}
private fun encodeFlag(numberOfComponents: Pair<Int, Int>, hashBuilder: StringBuilder) {
val sizeFlag = (numberOfComponents.first - 1) + (numberOfComponents.second - 1) * 9
hashBuilder.append(encode83(sizeFlag, 1))
}
private fun encodeMaximumValue(ac: List<Triple<Float, Float, Float>>, hash: StringBuilder): Float {
val maximumValue: Float
if (ac.isNotEmpty()) {
val actualMaximumValue = ac.maxOf { t -> max(max(abs(t.first), abs(t.second)), abs(t.third)) }
val quantisedMaximumValue = max(0f, min(82f, floor(actualMaximumValue * 166f - 0.5f))).toInt()
maximumValue = (quantisedMaximumValue + 1).toFloat() / 166f
hash.append(encode83(quantisedMaximumValue, 1))
} else {
maximumValue = 1f
hash.append(encode83(0, 1))
}
return maximumValue
}
private fun calculateBlurFactors(pixels: IntArray, width: Int, height: Int, numberOfComponents: Pair<Int, Int>): List<Triple<Float, Float, Float>> {
val factors = mutableListOf<Triple<Float, Float, Float>>()
for (y in 0 until numberOfComponents.second) {
for (x in 0 until numberOfComponents.first) {
val normalisation = if (x == 0 && y == 0) 1f else 2f
val factor = multiplyBasisFunction(pixels, width, height, x, y, normalisation)
factors.add(factor)
}
}
return factors
}
private fun encode83(value: Int, length: Int): String {
var result = ""
for (i in 1..length) {
val digit = (value / 83f.pow((length - i).toFloat())) % 83f
result += ENCODE_CHARACTERS[digit.toInt()]
}
return result
}
private const val ENCODE_CHARACTERS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
private fun encodeDC(value: Triple<Float, Float, Float>): Int {
val roundedR = BlurhashHelpers.linearTosRGB(value.first)
val roundedG = BlurhashHelpers.linearTosRGB(value.second)
val roundedB = BlurhashHelpers.linearTosRGB(value.third)
return (roundedR shl 16) + (roundedG shl 8) + roundedB
}
private fun encodeAC(value: Triple<Float, Float, Float>, maximumValue: Float): Int {
val quantR = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.first / maximumValue, 0.5f) * 9f + 9.5f)))
val quantG = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.second / maximumValue, 0.5f) * 9f + 9.5f)))
val quantB = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.third / maximumValue, 0.5f) * 9f + 9.5f)))
return (quantR * 19f * 19f + quantG * 19f + quantB).toInt()
}
private fun multiplyBasisFunction(pixels: IntArray, width: Int, height: Int, x: Int, y: Int, normalisation: Float): Triple<Float, Float, Float> {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until height) {
for (i in 0 until width) {
val basis = normalisation * cos(PI.toFloat() * x * i / width) * cos(PI.toFloat() * y * j / height)
val pixel = pixels[i + j * width]
val pr = BlurhashHelpers.srgbToLinear(Color.red(pixel))
val pg = BlurhashHelpers.srgbToLinear(Color.green(pixel))
val pb = BlurhashHelpers.srgbToLinear(Color.blue(pixel))
r += basis * pr
g += basis * pg
b += basis * pb
}
}
val scale = 1f / (width * height)
return Triple(r * scale, g * scale, b * scale)
}
}
@@ -0,0 +1,38 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import kotlin.math.*
object BlurhashHelpers {
fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
fun linearTosRGB(value: Float): Int {
val v = max(0f, min(1f, value))
return if (v <= 0.0031308) {
(v * 12.92 * 255 + 0.5).toInt()
} else {
(1.055 * (v.pow(1f / 2.4f) - 0.055) * 255 + 0.5).toInt()
}
}
fun signPow(value: Float, exp: Float): Float {
return abs(value).pow(exp) * sign(value)
}
fun getBitsPerPixel(bitmap: Bitmap): Int {
return when (bitmap.config) {
Bitmap.Config.ARGB_8888 -> 32
Bitmap.Config.RGB_565 -> 16
Bitmap.Config.ALPHA_8 -> 8
Bitmap.Config.ARGB_4444 -> 16
else -> 0
}
}
}
@@ -0,0 +1,9 @@
package expo.modules.image.blurhash
import android.net.Uri
data class BlurhashModel(
val uri: Uri,
val width: Int,
val height: Int
)
@@ -0,0 +1,29 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import android.net.Uri
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
class BlurhashModelLoader : ModelLoader<BlurhashModel, Bitmap> {
override fun handles(model: BlurhashModel): Boolean = true
override fun buildLoadData(
model: BlurhashModel,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<Bitmap> {
val blurhash = getPath(model.uri, 0, null) { it }
return ModelLoader.LoadData(
ObjectKey(model),
BlurHashFetcher(blurhash, model.width, model.height, 1f)
)
}
private fun <T> getPath(uri: Uri, index: Int, default: T, converter: (String) -> T): T {
val value = uri.pathSegments.getOrNull(index) ?: return default
return converter(value)
}
}
@@ -0,0 +1,13 @@
package expo.modules.image.blurhash
import android.graphics.Bitmap
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
class BlurhashModelLoaderFactory : ModelLoaderFactory<BlurhashModel, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<BlurhashModel, Bitmap> =
BlurhashModelLoader()
override fun teardown() = Unit
}
@@ -0,0 +1,16 @@
package expo.modules.image.blurhash
import android.content.Context
import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
@GlideModule
class BlurhashModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(BlurhashModel::class.java, Bitmap::class.java, BlurhashModelLoaderFactory())
}
}
@@ -0,0 +1,27 @@
package expo.modules.image.dataurls
import android.util.Base64
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
import java.nio.ByteBuffer
class Base64DataFetcher(private val data: String) : DataFetcher<ByteBuffer> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<ByteBuffer> = ByteBuffer::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ByteBuffer>) {
val base64Section = getBase64Section()
val data = Base64.decode(base64Section, Base64.DEFAULT)
val byteBuffer = ByteBuffer.wrap(data)
callback.onDataReady(byteBuffer)
}
private fun getBase64Section(): String {
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs.
val startOfBase64Section = data.indexOf(',')
return data.substring(startOfBase64Section + 1)
}
}
@@ -0,0 +1,24 @@
package expo.modules.image.dataurls
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
import java.nio.ByteBuffer
/**
* Loads an [java.io.InputStream] from a Base 64 encoded String.
*/
class Base64ModelLoader : ModelLoader<String, ByteBuffer> {
override fun handles(model: String): Boolean {
return model.startsWith("data:")
}
override fun buildLoadData(
model: String,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<ByteBuffer> {
return ModelLoader.LoadData(ObjectKey(model), Base64DataFetcher(model))
}
}
@@ -0,0 +1,12 @@
package expo.modules.image.dataurls
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import java.nio.ByteBuffer
class Base64ModelLoaderFactory : ModelLoaderFactory<String, ByteBuffer> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, ByteBuffer> =
Base64ModelLoader()
override fun teardown() = Unit
}
@@ -0,0 +1,16 @@
package expo.modules.image.dataurls
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
import java.nio.ByteBuffer
@GlideModule
class Base64Module : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(String::class.java, ByteBuffer::class.java, Base64ModelLoaderFactory())
}
}
@@ -0,0 +1,19 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
class DecodedFetcher(
private val drawable: Drawable
) : DataFetcher<Drawable> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<Drawable> = Drawable::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Drawable>) {
callback.onDataReady(drawable)
}
}
@@ -0,0 +1,5 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
class DecodedModel(val drawable: Drawable)
@@ -0,0 +1,18 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
class DecodedModelLoader : ModelLoader<DecodedModel, Drawable> {
override fun handles(model: DecodedModel): Boolean = true
override fun buildLoadData(
model: DecodedModel,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<Drawable> {
return ModelLoader.LoadData(ObjectKey(model), DecodedFetcher(model.drawable))
}
}
@@ -0,0 +1,13 @@
package expo.modules.image.decodedsource
import android.graphics.drawable.Drawable
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
class DecodedModelLoaderFactory : ModelLoaderFactory<DecodedModel, Drawable> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<DecodedModel, Drawable> =
DecodedModelLoader()
override fun teardown() = Unit
}
@@ -0,0 +1,16 @@
package expo.modules.image.decodedsource
import android.content.Context
import android.graphics.drawable.Drawable
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
@GlideModule
class DecodedModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(DecodedModel::class.java, Drawable::class.java, DecodedModelLoaderFactory())
}
}
@@ -0,0 +1,218 @@
package expo.modules.image.drawing
import android.content.Context
import android.graphics.Canvas
import android.graphics.Outline
import android.graphics.Path
import android.graphics.RectF
import android.os.Build
import android.view.View
import android.view.ViewOutlineProvider
import com.facebook.react.modules.i18nmanager.I18nUtil
import com.facebook.react.uimanager.FloatUtil
import com.facebook.react.uimanager.PixelUtil
import com.facebook.yoga.YogaConstants
import expo.modules.image.ifYogaUndefinedUse
class OutlineProvider(private val mContext: Context) : ViewOutlineProvider() {
enum class BorderRadiusConfig {
ALL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_RIGHT,
BOTTOM_LEFT,
TOP_START,
TOP_END,
BOTTOM_START,
BOTTOM_END
}
enum class CornerRadius {
TOP_LEFT,
TOP_RIGHT,
BOTTOM_RIGHT,
BOTTOM_LEFT
}
private var mLayoutDirection = View.LAYOUT_DIRECTION_LTR
private val mBounds = RectF()
val borderRadiiConfig = FloatArray(9) { YogaConstants.UNDEFINED }
private val mCornerRadii = FloatArray(4)
private var mCornerRadiiInvalidated = true
private val mConvexPath = Path()
private var mConvexPathInvalidated = true
init {
updateCornerRadiiIfNeeded()
}
private fun updateCornerRadiiIfNeeded() {
if (!mCornerRadiiInvalidated) {
return
}
val isRTL = mLayoutDirection == View.LAYOUT_DIRECTION_RTL
val isRTLSwap = I18nUtil.instance.doLeftAndRightSwapInRTL(mContext)
updateCornerRadius(
CornerRadius.TOP_LEFT,
BorderRadiusConfig.TOP_LEFT,
BorderRadiusConfig.TOP_RIGHT,
BorderRadiusConfig.TOP_START,
BorderRadiusConfig.TOP_END,
isRTL,
isRTLSwap
)
updateCornerRadius(
CornerRadius.TOP_RIGHT,
BorderRadiusConfig.TOP_RIGHT,
BorderRadiusConfig.TOP_LEFT,
BorderRadiusConfig.TOP_END,
BorderRadiusConfig.TOP_START,
isRTL,
isRTLSwap
)
updateCornerRadius(
CornerRadius.BOTTOM_LEFT,
BorderRadiusConfig.BOTTOM_LEFT,
BorderRadiusConfig.BOTTOM_RIGHT,
BorderRadiusConfig.BOTTOM_START,
BorderRadiusConfig.BOTTOM_END,
isRTL,
isRTLSwap
)
updateCornerRadius(
CornerRadius.BOTTOM_RIGHT,
BorderRadiusConfig.BOTTOM_RIGHT,
BorderRadiusConfig.BOTTOM_LEFT,
BorderRadiusConfig.BOTTOM_END,
BorderRadiusConfig.BOTTOM_START,
isRTL,
isRTLSwap
)
mCornerRadiiInvalidated = false
mConvexPathInvalidated = true
}
private fun updateCornerRadius(
outputPosition: CornerRadius,
inputPosition: BorderRadiusConfig,
oppositePosition: BorderRadiusConfig,
startPosition: BorderRadiusConfig,
endPosition: BorderRadiusConfig,
isRTL: Boolean,
isRTLSwap: Boolean
) {
var radius = borderRadiiConfig[inputPosition.ordinal]
if (isRTL) {
if (isRTLSwap) {
radius = borderRadiiConfig[oppositePosition.ordinal]
}
if (YogaConstants.isUndefined(radius)) {
radius = borderRadiiConfig[endPosition.ordinal]
}
} else {
if (YogaConstants.isUndefined(radius)) {
radius = borderRadiiConfig[startPosition.ordinal]
}
}
radius = radius
.ifYogaUndefinedUse(borderRadiiConfig[BorderRadiusConfig.ALL.ordinal])
.ifYogaUndefinedUse(0f)
mCornerRadii[outputPosition.ordinal] = PixelUtil.toPixelFromDIP(radius)
}
private fun updateConvexPathIfNeeded() {
if (!mConvexPathInvalidated) {
return
}
mConvexPath.reset()
mConvexPath.addRoundRect(
mBounds,
floatArrayOf(
mCornerRadii[CornerRadius.TOP_LEFT.ordinal],
mCornerRadii[CornerRadius.TOP_LEFT.ordinal],
mCornerRadii[CornerRadius.TOP_RIGHT.ordinal],
mCornerRadii[CornerRadius.TOP_RIGHT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_RIGHT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_RIGHT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_LEFT.ordinal],
mCornerRadii[CornerRadius.BOTTOM_LEFT.ordinal]
),
Path.Direction.CW
)
mConvexPathInvalidated = false
}
fun hasEqualCorners(): Boolean {
updateCornerRadiiIfNeeded()
val initialCornerRadius = mCornerRadii[0]
return mCornerRadii.all { initialCornerRadius == it }
}
fun setBorderRadius(radius: Float, position: Int): Boolean {
if (!FloatUtil.floatsEqual(borderRadiiConfig[position], radius)) {
borderRadiiConfig[position] = radius
mCornerRadiiInvalidated = true
return true
}
return false
}
private fun updateBoundsAndLayoutDirection(view: View) {
// Update layout direction
val layoutDirection = view.layoutDirection
if (mLayoutDirection != layoutDirection) {
mLayoutDirection = layoutDirection
mCornerRadiiInvalidated = true
}
// Update size
val left = 0
val top = 0
val right = view.width
val bottom = view.height
if (mBounds.left != left.toFloat() ||
mBounds.top != top.toFloat() ||
mBounds.right != right.toFloat() ||
mBounds.bottom != bottom.toFloat()
) {
mBounds[left.toFloat(), top.toFloat(), right.toFloat()] = bottom.toFloat()
mCornerRadiiInvalidated = true
}
}
override fun getOutline(view: View, outline: Outline) {
updateBoundsAndLayoutDirection(view)
// Calculate outline
updateCornerRadiiIfNeeded()
if (hasEqualCorners()) {
val cornerRadius = mCornerRadii[0]
if (cornerRadius > 0) {
outline.setRoundRect(0, 0, mBounds.width().toInt(), mBounds.height().toInt(), cornerRadius)
} else {
outline.setRect(0, 0, mBounds.width().toInt(), mBounds.height().toInt())
}
} else {
// Clipping is not supported when using a convex path, but drawing the elevation
// shadow is. For the particular case, we fallback to canvas clipping in the view
// which is supposed to call `clipCanvasIfNeeded` in its `draw` method.
updateConvexPathIfNeeded()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
outline.setPath(mConvexPath)
} else {
@Suppress("DEPRECATION")
outline.setConvexPath(mConvexPath)
}
}
}
fun clipCanvasIfNeeded(canvas: Canvas, view: View) {
updateBoundsAndLayoutDirection(view)
updateCornerRadiiIfNeeded()
if (!hasEqualCorners()) {
updateConvexPathIfNeeded()
canvas.clipPath(mConvexPath)
}
}
}
@@ -0,0 +1,89 @@
package expo.modules.image.enums
import android.graphics.Matrix
import android.graphics.RectF
import expo.modules.kotlin.types.Enumerable
import kotlin.math.max
/**
* Describes how the image should be resized to fit its container.
* - Note: It mirrors the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property.
*/
enum class ContentFit(val value: String) : Enumerable {
/**
* The image is scaled to maintain its aspect ratio while fitting within the container's box.
* The entire image is made to fill the box, while preserving its aspect ratio,
* so the image will be "letterboxed" if its aspect ratio does not match the aspect ratio of the box.
*/
Contain("contain"),
/**
* The image is sized to maintain its aspect ratio while filling the element's entire content box.
* If the image's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
*/
Cover("cover"),
/**
* The image is sized to fill the element's content box. The entire object will completely fill the box.
* If the image's aspect ratio does not match the aspect ratio of its box, then the image will be stretched to fit.
*/
Fill("fill"),
/**
* The image is not resized and is centered by default.
* When specified, the exact position can be controlled with `ContentPosition`.
*/
None("none"),
/**
* The image is sized as if `none` or `contain` were specified,
* whichever would result in a smaller concrete image size.
*/
ScaleDown("scale-down");
internal fun toMatrix(imageRect: RectF, viewRect: RectF, sourceWidth: Int, sourceHeight: Int) = Matrix().apply {
when (this@ContentFit) {
Contain -> setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
Cover -> {
val imageWidth = imageRect.width()
val imageHeight = imageRect.height()
val reactWidth = viewRect.width()
val reactHeight = viewRect.height()
val scale = max(reactWidth / imageWidth, reactHeight / imageHeight)
setScale(scale, scale)
}
Fill -> setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.FILL)
None -> {
// we don't need to do anything
}
ScaleDown -> {
// If we have information about the original size of the source, we can resize the image more drastically.
// In certain situations, we may even permit upscaling when we anticipate the image to be reloaded without any reduction in size,
// which will create a seamless transition between various states.
if (sourceWidth != -1 && sourceHeight != -1) {
val sourceRect = RectF(0f, 0f, sourceWidth.toFloat(), sourceHeight.toFloat())
// Rather than checking if the image rectangle is within the bounds of the view rectangle, we verify the original source rectangle.
// We know that the newly loaded image has larger dimensions than the current one, and therefore,
// it will not be downscaled.
if (sourceRect.width() >= viewRect.width() || sourceRect.height() >= viewRect.height()) {
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
}
// If the source rectangle is larger than the view rectangle and the image rectangle has not been upscaled to match the source rectangle,
// temporary upscaling is necessary to ensure a seamless transition.
// It should be noted that this upscaling is applied to the downscaled version of the image,
// not the original source image, and will be replaced by the original asset shortly thereafter.
else {
setRectToRect(imageRect, sourceRect, Matrix.ScaleToFit.START)
}
} else {
if (imageRect.width() >= viewRect.width() || imageRect.height() >= viewRect.height()) {
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
}
}
}
}
}
}
@@ -0,0 +1,14 @@
package expo.modules.image.enums
import com.bumptech.glide.load.DataSource
enum class ImageCacheType(private vararg val dataSources: DataSource) {
NONE(DataSource.LOCAL, DataSource.REMOTE),
DISK(DataSource.DATA_DISK_CACHE, DataSource.RESOURCE_DISK_CACHE),
MEMORY(DataSource.MEMORY_CACHE);
companion object {
fun fromNativeValue(value: DataSource): ImageCacheType =
entries.firstOrNull { it.dataSources.contains(value) } ?: NONE
}
}
@@ -0,0 +1,15 @@
package expo.modules.image.enums
import expo.modules.kotlin.types.Enumerable
enum class Priority(val value: String) : Enumerable {
LOW("low"),
NORMAL("normal"),
HIGH("high");
internal fun toGlidePriority(): com.bumptech.glide.Priority = when (this) {
LOW -> com.bumptech.glide.Priority.LOW
NORMAL -> com.bumptech.glide.Priority.NORMAL
HIGH -> com.bumptech.glide.Priority.IMMEDIATE
}
}
@@ -0,0 +1,77 @@
package expo.modules.image.events
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.Log
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import expo.modules.image.ExpoImageViewWrapper
import expo.modules.image.enums.ImageCacheType
import expo.modules.image.records.ImageErrorEvent
import expo.modules.image.records.ImageLoadEvent
import expo.modules.image.records.ImageSource
import expo.modules.image.svg.SVGPictureDrawable
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import java.util.Locale
class GlideRequestListener(
private val expoImageViewWrapper: WeakReference<ExpoImageViewWrapper>
) : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
val errorMessage = e
?.message
// Glide always append that line to the end of the message.
// It's not possible to call `logRootCauses` from the JS, so we decided to remove it.
?.removeSuffix("\n call GlideException#logRootCauses(String) for more detail")
?: "Unknown error"
expoImageViewWrapper
.get()
?.onError
?.invoke(ImageErrorEvent(errorMessage))
Log.e("ExpoImage", errorMessage)
e?.logRootCauses("ExpoImage")
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
val intrinsicWidth = (resource as? SVGPictureDrawable)?.svgIntrinsicWidth
?: resource.intrinsicWidth
val intrinsicHeight = (resource as? SVGPictureDrawable)?.svgIntrinsicHeight
?: resource.intrinsicHeight
val imageWrapper = expoImageViewWrapper.get() ?: return false
val appContext = imageWrapper.appContext
appContext.mainQueue.launch {
imageWrapper.onLoad.invoke(
ImageLoadEvent(
cacheType = ImageCacheType.fromNativeValue(dataSource).name.lowercase(Locale.getDefault()),
source = ImageSource(
url = model.toString(),
width = intrinsicWidth,
height = intrinsicHeight,
mediaType = null, // TODO(@lukmccall): add mediaType
isAnimated = resource is Animatable
)
)
)
}
return false
}
}
@@ -0,0 +1,26 @@
package expo.modules.image.events
import com.facebook.react.modules.network.ProgressListener
import expo.modules.image.ExpoImageViewWrapper
import expo.modules.image.records.ImageProgressEvent
import java.lang.ref.WeakReference
class OkHttpProgressListener(
private val expoImageViewWrapper: WeakReference<ExpoImageViewWrapper>
) : ProgressListener {
override fun onProgress(bytesWritten: Long, contentLength: Long, done: Boolean) {
// OkHttp calls that function twice at the end - when the last byte was downloaded with done set to false,
// and also shortly after, with done set to true. In both cases, the bytesWritten and the contentLength are equal.
// We want to avoid sending two same events to JS, that's why we return when done is set to true.
if (contentLength <= 0 || done) {
return
}
expoImageViewWrapper.get()?.onProgress?.invoke(
ImageProgressEvent(
loaded = bytesWritten.toInt(),
total = contentLength.toInt()
)
)
}
}
@@ -0,0 +1,80 @@
package expo.modules.image.okhttp
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.Headers
import com.bumptech.glide.module.LibraryGlideModule
import expo.modules.image.events.OkHttpProgressListener
import okhttp3.OkHttpClient
import java.io.InputStream
/**
* GlideUrl with custom cache key.
* It wraps the base implementation and overrides logic behind cache key and when two
* objects are equal. Typically, Glide uses the only cache key to compare objects.
* It won't suit our use case. We want to make custom cache key transparent and
* not use it to compare objects.
*/
class GlideUrlWithCustomCacheKey(
uri: String?,
headers: Headers?,
private val cacheKey: String
) : GlideUrl(uri, headers) {
/**
* Cached hash code value
*/
private var hashCode = 0
/**
* @return a super cache key from [GlideUrl]
*/
private fun getBaseCacheKey(): String = super.getCacheKey()
override fun getCacheKey(): String = cacheKey
// Mostly copied from GlideUrl::equal
override fun equals(other: Any?): Boolean {
if (other is GlideUrlWithCustomCacheKey) {
return getBaseCacheKey() == other.getBaseCacheKey() && headers.equals(other.headers)
} else if (other is GlideUrl) {
return getBaseCacheKey() == other.cacheKey && headers.equals(other.headers)
}
return false
}
// Mostly copied from GlideUrl::hashCode
override fun hashCode(): Int {
if (hashCode == 0) {
hashCode = getBaseCacheKey().hashCode()
hashCode = 31 * hashCode + headers.hashCode()
}
return hashCode
}
}
/**
* To connect listener with the request we have to create custom model.
* In that way, we're passing more information to the final data loader.
*/
data class GlideUrlWrapper(val glideUrl: GlideUrl) {
var progressListener: OkHttpProgressListener? = null
override fun toString(): String {
return glideUrl.toString()
}
}
@GlideModule
class ExpoImageOkHttpClientGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
val client = OkHttpClient()
// We don't use the `GlideUrl` directly but we want to replace the default okhttp loader anyway
// to make sure that the app will use only one client.
registry.replace(GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(client))
registry.prepend(GlideUrlWrapper::class.java, InputStream::class.java, GlideUrlWrapperLoader.Factory(client))
}
}
@@ -0,0 +1,55 @@
package expo.modules.image.okhttp
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.facebook.react.modules.network.ProgressResponseBody
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.io.InputStream
class GlideUrlWrapperLoader(
private val commonClient: OkHttpClient
) : ModelLoader<GlideUrlWrapper, InputStream> {
override fun buildLoadData(
model: GlideUrlWrapper,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<InputStream>? {
val loader = OkHttpUrlLoader(
commonClient
.newBuilder()
.addInterceptor(
Interceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse
.newBuilder()
.body(
ProgressResponseBody(requireNotNull(originalResponse.body)) { bytesWritten, contentLength, done ->
model.progressListener?.onProgress(bytesWritten, contentLength, done)
}
)
.build()
}
)
.build()
)
return loader.buildLoadData(model.glideUrl, width, height, options)
}
// The default http loader always returns true.
override fun handles(model: GlideUrlWrapper): Boolean = true
class Factory(
private val commonClient: OkHttpClient
) : ModelLoaderFactory<GlideUrlWrapper, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<GlideUrlWrapper, InputStream> =
GlideUrlWrapperLoader(commonClient)
override fun teardown() = Unit
}
}
@@ -0,0 +1,10 @@
package expo.modules.image.records
import expo.modules.kotlin.types.Enumerable
enum class CachePolicy(val value: String) : Enumerable {
NONE("none"),
DISK("disk"),
MEMORY("memory"),
MEMORY_AND_DISK("memory-disk")
}
@@ -0,0 +1,89 @@
package expo.modules.image.records
import android.graphics.Matrix
import android.graphics.RectF
import expo.modules.image.calcXTranslation
import expo.modules.image.calcYTranslation
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
/**
* Represents a position value that might be either `Double` or `String`.
* TODO(@lukmccall): Use `Either` instead of `Any`
*/
typealias ContentPositionValue = Any
private typealias CalcAxisOffset = (
value: Float,
imageRect: RectF,
viewRect: RectF,
isPercentage: Boolean,
isReverse: Boolean
) -> Float
class ContentPosition : Record {
@Field
val top: ContentPositionValue? = null
@Field
val bottom: ContentPositionValue? = null
@Field
val right: ContentPositionValue? = null
@Field
val left: ContentPositionValue? = null
private fun ContentPositionValue?.calcOffset(
isReverse: Boolean,
imageRect: RectF,
viewRect: RectF,
calcAxisOffset: CalcAxisOffset
): Float? {
if (this == null) {
return null
}
return if (this is Double) {
val value = this.toFloat()
calcAxisOffset(value, imageRect, viewRect, false, isReverse)
} else {
val value = this as String
if (value == "center") {
calcAxisOffset(50f, imageRect, viewRect, true, isReverse)
} else {
calcAxisOffset(value.removeSuffix("%").toFloat(), imageRect, viewRect, true, isReverse)
}
}
}
private fun offsetX(
imageRect: RectF,
viewRect: RectF
): Float {
return left.calcOffset(false, imageRect, viewRect, ::calcXTranslation)
?: right.calcOffset(true, imageRect, viewRect, ::calcXTranslation)
?: calcXTranslation(50f, imageRect, viewRect, isPercentage = true) // default value
}
private fun offsetY(
imageRect: RectF,
viewRect: RectF
): Float {
return top.calcOffset(false, imageRect, viewRect, ::calcYTranslation)
?: bottom.calcOffset(true, imageRect, viewRect, ::calcYTranslation)
?: calcYTranslation(50f, imageRect, viewRect, isPercentage = true) // default value
}
internal fun apply(to: Matrix, imageRect: RectF, viewRect: RectF) {
val xOffset = offsetX(imageRect, viewRect)
val yOffset = offsetY(imageRect, viewRect)
to.postTranslate(xOffset, yOffset)
}
companion object {
val center = ContentPosition()
}
}
@@ -0,0 +1,22 @@
package expo.modules.image.records
import expo.modules.kotlin.types.Enumerable
enum class DecodeFormat(val value: String) : Enumerable {
ARGB_8888("argb"),
RGB_565("rgb");
fun toGlideFormat(): com.bumptech.glide.load.DecodeFormat {
return when (this) {
ARGB_8888 -> com.bumptech.glide.load.DecodeFormat.PREFER_ARGB_8888
RGB_565 -> com.bumptech.glide.load.DecodeFormat.PREFER_RGB_565
}
}
fun toBytes(): Int {
return when (this) {
ARGB_8888 -> 4
RGB_565 -> 2
}
}
}
@@ -0,0 +1,12 @@
package expo.modules.image.records
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
data class ImageLoadOptions(
@Field
val maxWidth: Int = SIZE_ORIGINAL,
@Field
val maxHeight: Int = SIZE_ORIGINAL
) : Record
@@ -0,0 +1,8 @@
package expo.modules.image.records
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
data class ImageTransition(
@Field val duration: Int = 0
) : Record
@@ -0,0 +1,192 @@
package expo.modules.image.records
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.Headers
import com.bumptech.glide.load.model.LazyHeaders
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ApplicationVersionSignature
import expo.modules.image.BlurhashModelProvider
import expo.modules.image.DecodedModelProvider
import expo.modules.image.GlideModelProvider
import expo.modules.image.RawModelProvider
import expo.modules.image.ThumbhashModelProvider
import expo.modules.image.UriModelProvider
import expo.modules.image.UrlModelProvider
import expo.modules.image.ResourceIdHelper
import expo.modules.image.customize
import expo.modules.image.okhttp.GlideUrlWithCustomCacheKey
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
sealed interface Source {
val width: Int
val height: Int
val scale: Double
val pixelCount: Double
get() = width * height * scale * scale
fun createGlideModelProvider(context: Context): GlideModelProvider?
fun createGlideOptions(context: Context): RequestOptions
/**
* Whether it should use placeholder content fit when used as a placeholder
*/
fun usesPlaceholderContentFit(): Boolean = true
}
class DecodedSource(
val drawable: Drawable
) : Source {
override fun createGlideModelProvider(context: Context): GlideModelProvider {
return DecodedModelProvider(drawable)
}
override val width: Int = drawable.intrinsicWidth
override val height: Int = drawable.intrinsicHeight
override val scale: Double = 1.0
override fun createGlideOptions(context: Context): RequestOptions {
// We don't want to cache already decoded images.
return RequestOptions()
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
}
}
data class SourceMap(
@Field val uri: String? = null,
@Field override val width: Int = 0,
@Field override val height: Int = 0,
@Field override val scale: Double = 1.0,
@Field val headers: Map<String, String>? = null,
@Field val cacheKey: String? = null
) : Source, Record {
private var parsedUri: Uri? = null
private fun isDataUrl() = parsedUri?.scheme?.startsWith("data") ?: false
private fun isContentUrl() = parsedUri?.scheme?.startsWith("content") ?: false
private fun isResourceUri() = parsedUri?.scheme?.startsWith("android.resource") ?: false
private fun isLocalResourceUri() = parsedUri?.scheme?.startsWith("res") ?: false
private fun isLocalFileUri() = parsedUri?.scheme?.startsWith("file") ?: false
private fun isBlurhash() = parsedUri?.scheme?.startsWith("blurhash") ?: false
private fun isThumbhash() = parsedUri?.scheme?.startsWith("thumbhash") ?: false
override fun usesPlaceholderContentFit(): Boolean {
return !isBlurhash() && !isThumbhash()
}
private fun parseUri(context: Context) {
if (parsedUri == null) {
parsedUri = computeUri(context)
}
}
override fun createGlideModelProvider(context: Context): GlideModelProvider? {
if (uri.isNullOrBlank()) {
return null
}
parseUri(context)
if (isContentUrl() || isDataUrl()) {
return RawModelProvider(uri)
}
if (isBlurhash()) {
return BlurhashModelProvider(
parsedUri!!,
width,
height
)
}
if (isThumbhash()) {
return ThumbhashModelProvider(
parsedUri!!
)
}
if (isResourceUri()) {
return UriModelProvider(parsedUri!!)
}
if (isLocalResourceUri()) {
return UriModelProvider(
// Convert `res:/` scheme to `android.resource://`.
// Otherwise, glide can't understand the Uri.
Uri.parse(parsedUri!!.toString().replace("res:/", "android.resource://" + context.packageName + "/"))
)
}
if (isLocalFileUri()) {
return RawModelProvider(parsedUri!!.toString())
}
val glideUrl = if (cacheKey == null) {
GlideUrl(uri, getCustomHeaders())
} else {
GlideUrlWithCustomCacheKey(uri, getCustomHeaders(), cacheKey)
}
return UrlModelProvider(glideUrl)
}
override fun createGlideOptions(context: Context): RequestOptions {
parseUri(context)
return RequestOptions().customize(`when` = width != 0 && height != 0) {
// Override the size for local assets (apart from SVGs). This ensures that
// resizeMode "center" displays the image in the correct size.
override((width * scale).toInt(), (height * scale).toInt())
}.customize(`when` = isResourceUri()) {
// Every local resource (drawable) in Android has its own unique numeric id, which are
// generated at build time. Although these ids are unique, they are not guaranteed unique
// across builds. The underlying glide implementation caches these resources. To make
// sure the cache does not return the wrong image, we should clear the cache when the
// application version changes.
apply(RequestOptions.signatureOf(ApplicationVersionSignature.obtain(context)))
}
}
private fun getCustomHeaders(): Headers {
if (headers == null) {
return LazyHeaders.DEFAULT
}
return LazyHeaders
.Builder()
.apply {
headers.forEach { (key, value) ->
addHeader(key, value)
}
}
.build()
}
private fun computeUri(context: Context): Uri? {
val stringUri = uri ?: return null
return try {
val uri: Uri = Uri.parse(stringUri)
// Verify scheme is set, so that relative uri (used by static resources) are not handled.
if (uri.scheme == null) {
computeLocalUri(stringUri, context)
} else {
uri
}
} catch (e: Exception) {
computeLocalUri(stringUri, context)
}
}
private fun computeLocalUri(stringUri: String, context: Context): Uri? {
return ResourceIdHelper.getResourceUri(context, stringUri)
}
}
@@ -0,0 +1,26 @@
package expo.modules.image.records
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
data class ImageSource(
@Field val url: String,
@Field val width: Int,
@Field val height: Int,
@Field val mediaType: String?,
@Field val isAnimated: Boolean
) : Record
data class ImageLoadEvent(
@Field val cacheType: String,
@Field val source: ImageSource
) : Record
data class ImageProgressEvent(
@Field val loaded: Int,
@Field val total: Int
) : Record
data class ImageErrorEvent(
@Field val error: String
) : Record
@@ -0,0 +1,42 @@
package expo.modules.image.svg
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import java.io.IOException
import java.io.InputStream
/**
* Decodes an SVG internal representation from an [InputStream].
*
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgDecoder.java
* and rewritten to Kotlin.
*/
class SVGDecoder : ResourceDecoder<InputStream, SVG> {
// TODO: Can we tell?
override fun handles(source: InputStream, options: Options) = true
@Throws(IOException::class)
override fun decode(source: InputStream, width: Int, height: Int, options: Options): Resource<SVG>? {
return try {
val svg: SVG = SVG.getFromInputStream(source)
// Use document width and height if view box is not set.
// Later, we will override the document width and height with the dimensions of the native view.
if (svg.documentViewBox == null) {
val documentWidth = svg.documentWidth
val documentHeight = svg.documentHeight
if (documentWidth != -1f && documentHeight != -1f) {
svg.setDocumentViewBox(0f, 0f, documentWidth, documentHeight)
}
}
svg.documentWidth = width.toFloat()
svg.documentHeight = height.toFloat()
SimpleResource(svg)
} catch (ex: SVGParseException) {
throw IOException("Cannot load SVG from stream", ex)
}
}
}
@@ -0,0 +1,49 @@
package expo.modules.image.svg
import android.content.Context
import android.graphics.Picture
import android.graphics.drawable.Drawable
import android.graphics.drawable.PictureDrawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.applyTintColor
import expo.modules.image.CustomOptions
/**
* We have to use the intrinsicWidth/Height from the Picture to render the image at a high enough resolution, but at the same time we want to return the actual
* preferred width and height of the SVG to JS. This class allows us to do that.
*/
class SVGPictureDrawable(picture: Picture, val svgIntrinsicWidth: Int, val svgIntrinsicHeight: Int) : PictureDrawable(picture)
/**
* Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]).
*
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgDrawableTranscoder.java
* and rewritten to Kotlin.
*/
class SVGDrawableTranscoder(val context: Context) : ResourceTranscoder<SVG?, Drawable> {
override fun transcode(toTranscode: Resource<SVG?>, options: Options): Resource<Drawable> {
val svgData = toTranscode.get()
// If the svg doesn't have a viewBox, we can't determine its intrinsic width and height, so we default to 512x512.
// Same dimensions are used in the AndroidSVG library when the viewBox is not set.
val intrinsicWidth = svgData.documentViewBox?.width()?.toInt() ?: 512
val intrinsicHeight = svgData.documentViewBox?.height()?.toInt() ?: 512
val tintColor = options.get(CustomOptions.tintColor)
if (tintColor != null) {
applyTintColor(svgData, tintColor)
}
val picture = SVGPictureDrawable(
svgData.renderToPicture(),
intrinsicWidth,
intrinsicHeight
)
return SimpleResource(
picture
)
}
}
@@ -0,0 +1,26 @@
package expo.modules.image.svg
import android.content.Context
import android.graphics.drawable.Drawable
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
import com.caverock.androidsvg.SVG
import java.io.InputStream
/**
* [LibraryGlideModule] registering support for SVG to Glide.
*
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgModule.java
* and rewritten to Kotlin.
*/
@GlideModule
class SVGModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry
.append(InputStream::class.java, SVG::class.java, SVGDecoder())
.register(SVG::class.java, Drawable::class.java, SVGDrawableTranscoder(context))
}
}
@@ -0,0 +1,222 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import android.graphics.Color
// ThumbHash Java implementation (converted to kotlin) thanks to @evanw https://github.com/evanw/thumbhash
object ThumbhashDecoder {
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The width, height, and pixels of the rendered placeholder image.
*/
fun thumbHashToRGBA(hash: ByteArray): Image {
// Read the constants
val header24 = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
val header16 = hash[3].toInt() and 255 or (hash[4].toInt() and 255 shl 8)
val l_dc = (header24 and 63).toFloat() / 63.0f
val p_dc = (header24 shr 6 and 63).toFloat() / 31.5f - 1.0f
val q_dc = (header24 shr 12 and 63).toFloat() / 31.5f - 1.0f
val l_scale = (header24 shr 18 and 31).toFloat() / 31.0f
val hasAlpha = header24 shr 23 != 0
val p_scale = (header16 shr 3 and 63).toFloat() / 63.0f
val q_scale = (header16 shr 9 and 63).toFloat() / 63.0f
val isLandscape = header16 shr 15 != 0
val lx = Math.max(3, if (isLandscape) if (hasAlpha) 5 else 7 else header16 and 7)
val ly = Math.max(3, if (isLandscape) header16 and 7 else if (hasAlpha) 5 else 7)
val a_dc = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
val a_scale = (hash[5].toInt() shr 4 and 15).toFloat() / 15.0f
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
val ac_start = if (hasAlpha) 6 else 5
var ac_index = 0
val l_channel = Channel(lx, ly)
val p_channel = Channel(3, 3)
val q_channel = Channel(3, 3)
var a_channel: Channel? = null
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale)
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f)
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f)
if (hasAlpha) {
a_channel = Channel(5, 5)
a_channel.decode(hash, ac_start, ac_index, a_scale)
}
val l_ac = l_channel.ac
val p_ac = p_channel.ac
val q_ac = q_channel.ac
val a_ac = if (hasAlpha) a_channel!!.ac else null
// Decode using the DCT into RGB
val ratio = thumbHashToApproximateAspectRatio(hash)
val w = Math.round(if (ratio > 1.0f) 32.0f else 32.0f * ratio)
val h = Math.round(if (ratio > 1.0f) 32.0f / ratio else 32.0f)
val rgba = ByteArray(w * h * 4)
val cx_stop = Math.max(lx, if (hasAlpha) 5 else 3)
val cy_stop = Math.max(ly, if (hasAlpha) 5 else 3)
val fx = FloatArray(cx_stop)
val fy = FloatArray(cy_stop)
var y = 0
var i = 0
while (y < h) {
var x = 0
while (x < w) {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
for (cx in 0 until cx_stop) fx[cx] = Math.cos(Math.PI / w * (x + 0.5f) * cx).toFloat()
for (cy in 0 until cy_stop) fy[cy] = Math.cos(Math.PI / h * (y + 0.5f) * cy).toFloat()
// Decode L
run {
var cy = 0
var j = 0
while (cy < ly) {
val fy2 = fy[cy] * 2.0f
var cx = if (cy > 0) 0 else 1
while (cx * ly < lx * (ly - cy)) {
l += l_ac[j] * fx[cx] * fy2
cx++
j++
}
cy++
}
}
// Decode P and Q
var cy = 0
var j = 0
while (cy < 3) {
val fy2 = fy[cy] * 2.0f
var cx = if (cy > 0) 0 else 1
while (cx < 3 - cy) {
val f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
cx++
j++
}
cy++
}
// Decode A
if (hasAlpha) {
var cyAlpha = 0
var k = 0
while (cyAlpha < 5) {
val fy2 = fy[cyAlpha] * 2.0f
var cx = if (cyAlpha > 0) 0 else 1
while (cx < 5 - cyAlpha) {
a += a_ac!![k] * fx[cx] * fy2
cx++
k++
}
cyAlpha++
}
}
// Convert to RGB
val b = l - 2.0f / 3.0f * p
val r = (3.0f * l - b + q) / 2.0f
val g = r - q
rgba[i] = Math.max(0, Math.round(255.0f * Math.min(1f, r))).toByte()
rgba[i + 1] = Math.max(0, Math.round(255.0f * Math.min(1f, g))).toByte()
rgba[i + 2] = Math.max(0, Math.round(255.0f * Math.min(1f, b))).toByte()
rgba[i + 3] = Math.max(0, Math.round(255.0f * Math.min(1f, a))).toByte()
x++
i += 4
}
y++
}
return Image(w, h, rgba)
}
/**
* Converts a ThumbHash into a Bitmap image
*/
fun thumbHashToBitmap(hash: ByteArray): Bitmap {
val thumbhashImage = thumbHashToRGBA(hash)
// TODO: @behenate it should be possible to replace all of the code below with
// with BitmapFactory.decodeByteArray but it always returns null when using thumbhashImage.rgba
val imageArray = IntArray(thumbhashImage.width * thumbhashImage.height)
val thumbhashImageInt = thumbhashImage.rgba.map { it.toUByte().toInt() }
for (i in thumbhashImageInt.indices step 4) {
imageArray[i / 4] = Color.argb(
thumbhashImageInt[i + 3],
thumbhashImageInt[i],
thumbhashImageInt[i + 1],
thumbhashImageInt[i + 2]
)
}
return Bitmap.createBitmap(imageArray, thumbhashImage.width, thumbhashImage.height, Bitmap.Config.ARGB_8888)
}
/**
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The RGBA values for the average color. Each value ranges from 0 to 1.
*/
fun thumbHashToAverageRGBA(hash: ByteArray): RGBA {
val header = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
val l = (header and 63).toFloat() / 63.0f
val p = (header shr 6 and 63).toFloat() / 31.5f - 1.0f
val q = (header shr 12 and 63).toFloat() / 31.5f - 1.0f
val hasAlpha = header shr 23 != 0
val a = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
val b = l - 2.0f / 3.0f * p
val r = (3.0f * l - b + q) / 2.0f
val g = r - q
return RGBA(
Math.max(0f, Math.min(1f, r)),
Math.max(0f, Math.min(1f, g)),
Math.max(0f, Math.min(1f, b)),
a
)
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @return The approximate aspect ratio (i.e. width / height).
*/
fun thumbHashToApproximateAspectRatio(hash: ByteArray): Float {
val header = hash[3]
val hasAlpha = hash[2].toInt() and 0x80 != 0
val isLandscape = hash[4].toInt() and 0x80 != 0
val lx = if (isLandscape) if (hasAlpha) 5 else 7 else header.toInt() and 7
val ly = if (isLandscape) header.toInt() and 7 else if (hasAlpha) 5 else 7
return lx.toFloat() / ly.toFloat()
}
class Image(var width: Int, var height: Int, var rgba: ByteArray)
class RGBA(var r: Float, var g: Float, var b: Float, var a: Float)
private class Channel(nx: Int, ny: Int) {
var ac: FloatArray
init {
var n = 0
for (cy in 0 until ny) {
var cx = if (cy > 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
n++
cx++
}
}
ac = FloatArray(n)
}
fun decode(hash: ByteArray, start: Int, index: Int, scale: Float): Int {
var currentIndex = index
for (i in ac.indices) {
val data = hash[start + (currentIndex shr 1)].toInt() shr (currentIndex and 1 shl 2)
ac[i] = ((data and 15).toFloat() / 7.5f - 1.0f) * scale
currentIndex++
}
return currentIndex
}
}
}
@@ -0,0 +1,184 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import android.graphics.Color
// ThumbHash Java implementation (converted to kotlin) thanks to @evanw https://github.com/evanw/thumbhash
object ThumbhashEncoder {
/**
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
* @param bitmap The bitmap to generate the ThumbHash from.
* @return The ThumbHash as a byte array.
*/
fun encode(bitmap: Bitmap): ByteArray {
// Encoding an image larger than 100x100 is slow with no benefit
val resizedBitmap = resizeKeepingAspectRatio(bitmap, 100)
val w = resizedBitmap.width
val h = resizedBitmap.height
val pixels = IntArray(w * h)
resizedBitmap.getPixels(pixels, 0, w, 0, 0, w, h)
var avg_r = 0f
var avg_g = 0f
var avg_b = 0f
var avg_a = 0f
var i = 0
while (i < w * h) {
val alpha = Color.alpha(pixels[i]) / 255.0f
avg_r += alpha / 255.0f * Color.red(pixels[i])
avg_g += alpha / 255.0f * Color.green(pixels[i])
avg_b += alpha / 255.0f * Color.blue(pixels[i])
avg_a += alpha
i++
}
if (avg_a > 0) {
avg_r /= avg_a
avg_g /= avg_a
avg_b /= avg_a
}
val hasAlpha = avg_a < w * h
val l_limit = if (hasAlpha) 5 else 7 // Use fewer luminance bits if there's alpha
val lx = Math.max(1, Math.round((l_limit * w).toFloat() / Math.max(w, h).toFloat()))
val ly = Math.max(1, Math.round((l_limit * h).toFloat() / Math.max(w, h).toFloat()))
val l = FloatArray(w * h) // luminance
val p = FloatArray(w * h) // yellow - blue
val q = FloatArray(w * h) // red - green
val a = FloatArray(w * h) // alpha
// Convert the image from RGBA to LPQA (composite atop the average color)
i = 0
while (i < w * h) {
val alpha = (Color.alpha(pixels[i]) and 255) / 255.0f
val r = avg_r * (1.0f - alpha) + alpha / 255.0f * Color.red(pixels[i])
val g = avg_g * (1.0f - alpha) + alpha / 255.0f * Color.green(pixels[i])
val b = avg_b * (1.0f - alpha) + alpha / 255.0f * Color.blue(pixels[i])
l[i] = (r + g + b) / 3.0f
p[i] = (r + g) / 2.0f - b
q[i] = r - g
a[i] = alpha
i++
}
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
val l_channel = Channel(Math.max(3, lx), Math.max(3, ly)).encode(w, h, l)
val p_channel = Channel(3, 3).encode(w, h, p)
val q_channel = Channel(3, 3).encode(w, h, q)
val a_channel = if (hasAlpha) Channel(5, 5).encode(w, h, a) else null
// Write the constants
val isLandscape = w > h
val header24 = (
Math.round(63.0f * l_channel.dc)
or (Math.round(31.5f + 31.5f * p_channel.dc) shl 6)
or (Math.round(31.5f + 31.5f * q_channel.dc) shl 12)
or (Math.round(31.0f * l_channel.scale) shl 18)
or if (hasAlpha) 1 shl 23 else 0
)
val header16 = (
(if (isLandscape) ly else lx)
or (Math.round(63.0f * p_channel.scale) shl 3)
or (Math.round(63.0f * q_channel.scale) shl 9)
or if (isLandscape) 1 shl 15 else 0
)
val ac_start = if (hasAlpha) 6 else 5
val ac_count = (
l_channel.ac.size + p_channel.ac.size + q_channel.ac.size +
if (hasAlpha) a_channel!!.ac.size else 0
)
val hash = ByteArray(ac_start + (ac_count + 1) / 2)
hash[0] = header24.toByte()
hash[1] = (header24 shr 8).toByte()
hash[2] = (header24 shr 16).toByte()
hash[3] = header16.toByte()
hash[4] = (header16 shr 8).toByte()
if (hasAlpha) {
hash[5] = (
Math.round(15.0f * a_channel!!.dc)
or (Math.round(15.0f * a_channel.scale) shl 4)
).toByte()
}
// Write the varying factors
var ac_index = 0
ac_index = l_channel.writeTo(hash, ac_start, ac_index)
ac_index = p_channel.writeTo(hash, ac_start, ac_index)
ac_index = q_channel.writeTo(hash, ac_start, ac_index)
if (hasAlpha) a_channel!!.writeTo(hash, ac_start, ac_index)
return hash
}
private fun resizeKeepingAspectRatio(bitmap: Bitmap, maxSize: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val ratio = width.toFloat() / height.toFloat()
val newWidth: Int
val newHeight: Int
if (ratio > 1) {
newWidth = maxSize
newHeight = (maxSize / ratio).toInt()
} else {
newHeight = maxSize
newWidth = (maxSize * ratio).toInt()
}
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
private class Channel(var nx: Int, var ny: Int) {
var dc = 0f
var ac: FloatArray
var scale = 0f
init {
var n = 0
for (cy in 0 until ny) {
var cx = if (cy > 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
n++
cx++
}
}
ac = FloatArray(n)
}
fun encode(w: Int, h: Int, channel: FloatArray): Channel {
var n = 0
val fx = FloatArray(w)
for (cy in 0 until ny) {
var cx = 0
while (cx * ny < nx * (ny - cy)) {
var f = 0f
for (x in 0 until w) fx[x] = Math.cos(Math.PI / w * cx * (x + 0.5f)).toFloat()
for (y in 0 until h) {
val fy = Math.cos(Math.PI / h * cy * (y + 0.5f)).toFloat()
for (x in 0 until w) f += channel[x + y * w] * fx[x] * fy
}
f /= (w * h).toFloat()
if (cx > 0 || cy > 0) {
ac[n++] = f
scale = Math.max(scale, Math.abs(f))
} else {
dc = f
}
cx++
}
}
if (scale > 0) for (i in ac.indices) ac[i] = 0.5f + 0.5f / scale * ac[i]
return this
}
fun writeTo(hash: ByteArray, start: Int, index: Int): Int {
var currentIndex = index
for (v in ac) {
hash[start + (currentIndex shr 1)] = (hash[start + (currentIndex shr 1)].toInt() or (Math.round(15.0f * v) shl (currentIndex and 1 shl 2))).toByte()
currentIndex++
}
return currentIndex
}
}
}
@@ -0,0 +1,31 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import android.util.Base64
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
import expo.modules.kotlin.exception.CodedException
class ThumbhashDecodingFailure(thumbhash: String?, cause: Exception?) : CodedException(
message = "Cannot decode provided thumbhash '$thumbhash' $cause"
)
class ThumbhashFetcher(
private val thumbhash: String?
) : DataFetcher<Bitmap> {
override fun cleanup() = Unit
override fun cancel() = Unit
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
try {
val decodedThumbhash = Base64.decode(thumbhash, Base64.DEFAULT)
val bitmap = ThumbhashDecoder.thumbHashToBitmap(decodedThumbhash)
callback.onDataReady(bitmap)
} catch (e: Exception) {
callback.onLoadFailed(ThumbhashDecodingFailure(thumbhash, e))
}
}
}
@@ -0,0 +1,7 @@
package expo.modules.image.thumbhash
import android.net.Uri
data class ThumbhashModel(
val uri: Uri
)
@@ -0,0 +1,24 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
class ThumbhashModelLoader : ModelLoader<ThumbhashModel, Bitmap> {
override fun handles(model: ThumbhashModel): Boolean = true
override fun buildLoadData(model: ThumbhashModel, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
// The URI looks like this: thumbhash:/3OcRJYB4d3h\iIeHeEh3eIhw+j2w
// ThumbHash may include slashes which could break the structure of the URL, so we replace them
// with backslashes on the JS side and revert them back to slashes here, before generating the image.
val thumbhash = model.uri.pathSegments.joinToString(separator = "/").replace("\\", "/")
return ModelLoader.LoadData(
ObjectKey(model),
ThumbhashFetcher(thumbhash)
)
}
}
@@ -0,0 +1,13 @@
package expo.modules.image.thumbhash
import android.graphics.Bitmap
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
class ThumbhashModelLoaderFactory : ModelLoaderFactory<ThumbhashModel, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<ThumbhashModel, Bitmap> =
ThumbhashModelLoader()
override fun teardown() = Unit
}
@@ -0,0 +1,16 @@
package expo.modules.image.thumbhash
import android.content.Context
import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.LibraryGlideModule
@GlideModule
class ThumbhashModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
registry.prepend(ThumbhashModel::class.java, Bitmap::class.java, ThumbhashModelLoaderFactory())
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["ImageModule"]
},
"android": {
"modules": ["expo.modules.image.ExpoImageModule"],
"publication": {
"groupId": "BareExpo",
"artifactId": "expo.modules.image",
"version": "3.0.11",
"repository": "local-maven-repo"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import SDWebImage
/**
Custom `SDAnimatedImage` that fixes issues with `images` and `duration` not being available.
*/
final class AnimatedImage: SDAnimatedImage {
var frames: [SDImageFrame]?
// MARK: - UIImage
override var images: [UIImage]? {
preloadAllFrames()
return frames?.map({ $0.image })
}
override var duration: TimeInterval {
preloadAllFrames()
return frames?.reduce(0, { $0 + $1.duration }) ?? 0.0
}
// MARK: - SDAnimatedImage
override func preloadAllFrames() {
if frames != nil {
return
}
frames = [UInt](0..<animatedImageFrameCount).compactMap { index in
guard let image = animatedImageFrame(at: index) else {
return nil
}
let duration = animatedImageDuration(at: index)
return SDImageFrame(image: image, duration: duration)
}
}
}
+326
View File
@@ -0,0 +1,326 @@
// The blurhash algorithm was entirely created by Wolt Enterprises.
// This implementation was inspired by:
// - https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashDecode.swift
// - https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashEncode.swift
// See https://blurha.sh for more details about the blurhash.
import UIKit
// swiftlint:disable force_unwrapping
internal func image(fromBlurhash blurhash: String, size: CGSize, punch: Float = 1.0) -> CGImage? {
guard blurhash.count >= 6 else {
return nil
}
let sizeFlag = decode83(String(blurhash[0]))
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1
let quantisedMaximumValue = decode83(String(blurhash[1]))
let maximumValue = Float(quantisedMaximumValue + 1) / 166
guard blurhash.count == 4 + 2 * numX * numY else {
return nil
}
let colors: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
if i == 0 {
let value = decode83(String(blurhash[2 ..< 6]))
return decodeDC(value)
} else {
let value = decode83(String(blurhash[4 + i * 2 ..< 4 + i * 2 + 2]))
return decodeAC(value, maximumValue: maximumValue * punch)
}
}
let width = Int(size.width)
let height = Int(size.height)
let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else {
return nil
}
CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else {
return nil
}
for y in 0 ..< height {
for x in 0 ..< width {
var r: Float = 0
var g: Float = 0
var b: Float = 0
for j in 0 ..< numY {
for i in 0 ..< numX {
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
let color = colors[i + j * numX]
r += color.0 * basis
g += color.1 * basis
b += color.2 * basis
}
}
let intR = UInt8(linearTosRGB(r))
let intG = UInt8(linearTosRGB(g))
let intB = UInt8(linearTosRGB(b))
pixels[3 * x + 0 + y * bytesPerRow] = intR
pixels[3 * x + 1 + y * bytesPerRow] = intG
pixels[3 * x + 2 + y * bytesPerRow] = intB
}
}
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data), let cgImage = CGImage(
width: width,
height: height,
bitsPerComponent: 8,
bitsPerPixel: 24,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil,
shouldInterpolate: true,
intent: .defaultIntent
) else {
return nil
}
return cgImage
}
internal func blurhash(fromImage image: UIImage, numberOfComponents components: (Int, Int)) -> String? {
let size = image.size
let scale = image.scale
let pixelWidth = Int(round(size.width * scale))
let pixelHeight = Int(round(size.height * scale))
let context = CGContext(
data: nil,
width: pixelWidth,
height: pixelHeight,
bitsPerComponent: 8,
bytesPerRow: pixelWidth * 4,
space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
context.scaleBy(x: scale, y: -scale)
context.translateBy(x: 0, y: -size.height)
UIGraphicsPushContext(context)
image.draw(at: .zero)
UIGraphicsPopContext()
guard let cgImage = context.makeImage(),
let dataProvider = cgImage.dataProvider,
let data = dataProvider.data,
let pixels = CFDataGetBytePtr(data) else {
assertionFailure("Unexpected error!")
return nil
}
let width = cgImage.width
let height = cgImage.height
let bytesPerRow = cgImage.bytesPerRow
var factors: [(Float, Float, Float)] = []
for y in 0 ..< components.1 {
for x in 0 ..< components.0 {
let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
let factor = multiplyBasisFunction(
pixels: pixels,
width: width,
height: height,
bytesPerRow: bytesPerRow,
bytesPerPixel: cgImage.bitsPerPixel / 8,
pixelOffset: 0
) {
normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
}
factors.append(factor)
}
}
let dc = factors.first!
let ac = factors.dropFirst()
var hash = ""
let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
hash += encode83(sizeFlag, length: 1)
let maximumValue: Float
if !ac.isEmpty {
let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
maximumValue = Float(quantisedMaximumValue + 1) / 166
hash += encode83(quantisedMaximumValue, length: 1)
} else {
maximumValue = 1
hash += encode83(0, length: 1)
}
hash += encode83(encodeDC(dc), length: 4)
for factor in ac {
hash += encode83(encodeAC(factor, maximumValue: maximumValue), length: 2)
}
return hash
}
internal func isBlurhash(_ str: String) -> Bool {
return str.allSatisfy { char in decodeCharacters[char] != nil }
}
// MARK: - Encode
// swiftlint:disable:next function_parameter_count
private func multiplyBasisFunction(
pixels: UnsafePointer<UInt8>,
width: Int,
height: Int,
bytesPerRow: Int,
bytesPerPixel: Int,
pixelOffset: Int,
basisFunction: (Float, Float) -> Float
) -> (Float, Float, Float) {
var r: Float = 0
var g: Float = 0
var b: Float = 0
let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)
for x in 0 ..< width {
for y in 0 ..< height {
let basis = basisFunction(Float(x), Float(y))
r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
}
}
let scale = 1 / Float(width * height)
return (r * scale, g * scale, b * scale)
}
private let encodeCharacters: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~")
private func encodeDC(_ value: (Float, Float, Float)) -> Int {
let roundedR = linearTosRGB(value.0)
let roundedG = linearTosRGB(value.1)
let roundedB = linearTosRGB(value.2)
return (roundedR << 16) + (roundedG << 8) + roundedB
}
private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
return quantR * 19 * 19 + quantG * 19 + quantB
}
private func encode83(_ value: Int, length: Int) -> String {
var result = ""
for i in 1 ... length {
let digit = (value / pow(83, length - i)) % 83
result += String(encodeCharacters[Int(digit)])
}
return result
}
// MARK: - Decode
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
let intR = value >> 16
let intG = (value >> 8) & 255
let intB = value & 255
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
}
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19
let rgb = (
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
)
return rgb
}
private let decodeCharacters: [Character: Int] = {
var dict: [Character: Int] = [:]
for (index, character) in encodeCharacters.enumerated() {
dict[character] = index
}
return dict
}()
private func decode83(_ str: String) -> Int {
var value: Int = 0
for character in str {
if let digit = decodeCharacters[character] {
value = value * 83 + digit
}
}
return value
}
// MARK: - Helpers
private func signPow(_ value: Float, _ exp: Float) -> Float {
return copysign(pow(abs(value), exp), value)
}
private func linearTosRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 {
return Int(v * 12.92 * 255 + 0.5)
} else {
return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5)
}
}
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 {
return v / 12.92
} else {
return pow((v + 0.055) / 1.055, 2.4)
}
}
private func pow(_ base: Int, _ exponent: Int) -> Int {
return (0 ..< exponent).reduce(1) { value, _ in value * base }
}
private extension String {
subscript (offset: Int) -> Character {
return self[index(startIndex, offsetBy: offset)]
}
subscript (bounds: CountableClosedRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start...end]
}
subscript (bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start..<end]
}
}
+52
View File
@@ -0,0 +1,52 @@
import SDWebImage
import ExpoModulesCore
class BlurhashLoader: NSObject, SDImageLoader {
// MARK: - SDImageLoader
func canRequestImage(for url: URL?) -> Bool {
return url?.scheme == "blurhash"
}
func requestImage(
with url: URL?,
options: SDWebImageOptions = [],
context: [SDWebImageContextOption: Any]?,
progress progressBlock: SDImageLoaderProgressBlock?,
completed completedBlock: SDImageLoaderCompletedBlock? = nil
) -> SDWebImageOperation? {
guard let url = url else {
let error = makeNSError(description: "URL provided to BlurhashLoader is missing")
completedBlock?(nil, nil, error, false)
return nil
}
guard let source = context?[ImageView.contextSourceKey] as? ImageSource else {
let error = makeNSError(description: "Image source was not provided to the context")
completedBlock?(nil, nil, error, false)
return nil
}
// The URI looks like this: blurhash:/WgF}G?az0fs.x[jat7xFRjNHt6s.4;oe-:RkVtkCi^Nbo|xZRjWB
// Which means that the `pathComponents[0]` is `/` and we need to skip it to get the hash.
let blurhash = url.pathComponents[1]
let size = CGSize(width: source.width, height: source.height)
DispatchQueue.global(qos: .userInitiated).async {
if let image = image(fromBlurhash: blurhash, size: size) {
DispatchQueue.main.async {
completedBlock?(UIImage(cgImage: image), nil, nil, true)
}
} else {
let error = makeNSError(description: "Unable to generate an image from the given blurhash")
completedBlock?(nil, nil, error, false)
}
}
return nil
}
func shouldBlockFailedURL(with url: URL, error: Error) -> Bool {
// If the algorithm failed to generate an image from the url,
// it's not possible that next time it will work :)
return true
}
}
+55
View File
@@ -0,0 +1,55 @@
import ExpoModulesCore
/**
Describes how the image should be resized to fit its container.
- Note: It mirrors the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property.
*/
enum ContentFit: String, Enumerable {
/**
The image is scaled to maintain its aspect ratio while fitting within the container's box.
The entire image is made to fill the box, while preserving its aspect ratio,
so the image will be "letterboxed" if its aspect ratio does not match the aspect ratio of the box.
*/
case contain
/**
The image is sized to maintain its aspect ratio while filling the element's entire content box.
If the image's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
*/
case cover
/**
The image is sized to fill the element's content box. The entire object will completely fill the box.
If the image's aspect ratio does not match the aspect ratio of its box, then the image will be stretched to fit.
*/
case fill
/**
The image is not resized and is centered by default.
When specified, the exact position can be controlled with `ContentPosition`.
*/
case none
/**
The image is sized as if `none` or `contain` were specified,
whichever would result in a smaller concrete image size.
*/
case scaleDown = "scale-down"
/**
`ContentFit` cases can be directly translated to the native `UIView.ContentMode`
except `scaleDown` that needs to be handled differently at the later step of rendering.
*/
func toContentMode() -> UIView.ContentMode {
switch self {
case .contain:
return .scaleAspectFit
case .cover:
return .scaleAspectFill
case .fill:
return .scaleToFill
case .none, .scaleDown:
return .center
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import ExpoModulesCore
/**
Represents a position value that might be either `Double` or `String`.
*/
typealias ContentPositionValue = Either<Double, String>
/**
Specifies the alignment of the image within the container's box.
- Note: Its intention is to behave like the CSS [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) property.
*/
struct ContentPosition: Record {
static let center = Self()
@Field
var top: ContentPositionValue?
@Field
var bottom: ContentPositionValue?
@Field
var right: ContentPositionValue?
@Field
var left: ContentPositionValue?
/**
Returns a horizontal content offset based on the `left` or `right` field.
*/
func offsetX(contentWidth: Double, containerWidth: Double) -> Double {
let diff = containerWidth - contentWidth
if let leftDistance = distance(from: left) {
return -diff / 2 + leftDistance
}
if let rightDistance = distance(from: right) {
return diff / 2 - rightDistance
}
if let factor = factor(from: left) {
return -diff / 2 + diff * factor
}
if let factor = factor(from: right) {
return diff / 2 - diff * factor
}
return 0
}
/**
Returns a vertical content offset based on the `top` or `bottom` field.
*/
func offsetY(contentHeight: Double, containerHeight: Double) -> Double {
let diff = containerHeight - contentHeight
if let topDistance = distance(from: top) {
return -diff / 2 + topDistance
}
if let bottomDistance = distance(from: bottom) {
return diff / 2 - bottomDistance
}
if let factor = factor(from: top) {
return -diff / 2 + diff * factor
}
if let factor = factor(from: bottom) {
return diff / 2 - diff * factor
}
return 0
}
/**
A `CGPoint` with horizontal and vertical content offsets.
*/
func offset(contentSize: CGSize, containerSize: CGSize) -> CGPoint {
return CGPoint(
x: offsetX(contentWidth: contentSize.width, containerWidth: containerSize.width),
y: offsetY(contentHeight: contentSize.height, containerHeight: containerSize.height)
)
}
}
/**
Returns a static offset from the given position value or `nil` when it cannot be cast to a `Double`.
*/
private func distance(from value: ContentPositionValue?) -> Double? {
if let value: Double = value?.get() {
return value
}
if let value: String = value?.get() {
return Double(value)
}
return nil
}
/**
Returns a factor from the percentage value from the given position.
The value must be a string containing a number and `%` character, or equal to `"center"` which is an equivalent to `50%`.
*/
private func factor(from value: ContentPositionValue?) -> Double? {
guard let value: String = value?.get() else {
return nil
}
if value == "center" {
return 0.5
}
guard value.contains("%"), let percentage = Double(value.replacingOccurrences(of: "%", with: "")) else {
return nil
}
return percentage / 100
}
+41
View File
@@ -0,0 +1,41 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoImage'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.swift_version = '5.9'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'SDWebImage', '~> 5.21.0'
s.dependency 'SDWebImageAVIFCoder', '~> 0.11.0'
s.dependency 'SDWebImageSVGCoder', '~> 1.7.0'
s.dependency 'SDWebImageWebPCoder', '~> 0.14.6'
s.dependency 'libavif/libdav1d'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
}
s.source_files = "**/*.{h,m,swift}"
s.exclude_files = 'Tests/'
s.test_spec 'Tests' do |test_spec|
test_spec.dependency 'ExpoModulesTestCore'
test_spec.source_files = 'Tests/**/*.{m,swift}'
end
end
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal final class Image: SharedRef<UIImage> {
override var nativeRefType: String {
"image"
}
var isAnimated: Bool {
return !(ref.images?.isEmpty ?? true)
}
override func getAdditionalMemoryPressure() -> Int {
guard let cgImage = ref.cgImage else {
return 0
}
return cgImage.bytesPerRow * cgImage.height
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import SDWebImage
import ExpoModulesCore
enum ImageCachePolicy: String, Enumerable {
case none = "none"
case disk = "disk"
case memory = "memory"
case memoryAndDisk = "memory-disk"
func toSdCacheType() -> SDImageCacheType {
switch self {
case .none:
return .none
case .disk:
return .disk
case .memory:
return .memory
case .memoryAndDisk:
return .all
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import SDWebImage
import ExpoModulesCore
enum ImageCacheType: String, Enumerable {
case none
case disk
case memory
static func fromSdCacheType(_ sdImageCacheType: SDImageCacheType) -> ImageCacheType {
switch sdImageCacheType {
case .none:
return .none
case .disk, .all:
return .disk
case .memory:
return .memory
@unknown default:
log.error("Unhandled `SDImageCacheType` value: \(sdImageCacheType), returning `none` as fallback. Add the missing case as soon as possible.")
return .none
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal struct ImageLoadOptions: Record {
@Field var maxWidth: Int = .max
@Field var maxHeight: Int = .max
func getMaxSize() -> CGSize {
return CGSize(width: maxWidth, height: maxHeight)
}
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal final class ImageLoadTask: SharedObject {
private let source: ImageSource
private let maxSize: CGSize?
private var task: Task<UIImage, any Error>?
init(_ source: ImageSource, maxSize: CGSize? = nil) {
self.source = source
self.maxSize = maxSize
super.init()
}
func load() async throws -> UIImage {
let task = self.task ?? Task {
return try await ImageLoader.shared.load(source, maxSize: maxSize)
}
self.task = task
return try await task.value
}
func abort() {
task?.cancel()
}
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import SDWebImage
import ExpoModulesCore
internal final class ImageLoader {
static let shared = ImageLoader()
lazy var imageManager = SDWebImageManager(
cache: SDImageCache.shared,
loader: SDImageLoadersManager.shared
)
func load(_ source: ImageSource, maxSize: CGSize? = nil) async throws -> UIImage {
// This loader uses only the disk cache. We may want to give more control on this, but the memory cache
// doesn't make much sense for shared refs as they're kept in memory as long as their JS objects.
var context = createSDWebImageContext(forSource: source, cachePolicy: .disk)
if let maxSize {
context[.imageThumbnailPixelSize] = maxSize
}
return try await withCheckedThrowingContinuation { continuation in
imageManager.loadImage(with: source.uri, context: context, progress: nil) { image, _, error, _, _, _ in
if let image {
continuation.resume(returning: image)
} else {
continuation.resume(throwing: ImageLoadingFailed().causedBy(error))
}
}
}
}
}
internal final class ImageLoadingFailed: Exception {
override var reason: String {
"Failed to load an image"
}
}
+264
View File
@@ -0,0 +1,264 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
import SDWebImage
import SDWebImageAVIFCoder
import SDWebImageSVGCoder
public final class ImageModule: Module {
lazy var prefetcher = SDWebImagePrefetcher.shared
public func definition() -> ModuleDefinition {
Name("ExpoImage")
OnCreate {
ImageModule.registerCoders()
ImageModule.registerLoaders()
}
View(ImageView.self) {
Events(
"onLoadStart",
"onProgress",
"onError",
"onLoad",
"onDisplay"
)
Prop("source") { (view: ImageView, sources: Either<[ImageSource], SharedRef<UIImage>>?) in
if let imageRef: SharedRef<UIImage> = sources?.get() {
// Unset an array of traditional sources and just render the image ref right away.
view.sources = nil
view.renderSourceImage(imageRef.ref)
} else {
// Update an array of sources. Image will start loading once the all props are updated.
view.sources = sources?.get()
view.sourceImage = nil
}
}
Prop("placeholder") { (view, placeholders: [ImageSource]?) in
view.placeholderSources = placeholders ?? []
}
Prop("contentFit") { (view, contentFit: ContentFit?) in
view.contentFit = contentFit ?? .cover
}
Prop("placeholderContentFit") { (view, placeholderContentFit: ContentFit?) in
view.placeholderContentFit = placeholderContentFit ?? .scaleDown
}
Prop("contentPosition") { (view, contentPosition: ContentPosition?) in
view.contentPosition = contentPosition ?? .center
}
Prop("transition") { (view, transition: ImageTransition?) in
view.transition = transition
}
Prop("blurRadius") { (view, blurRadius: Double?) in
let radius = blurRadius ?? .zero
// the implementation uses Apple's CIGaussianBlur internally
// we divide the radius to achieve more consistent cross-platform appearance
// the value was found experimentally
view.blurRadius = radius / 2.0
}
Prop("tintColor") { (view, tintColor: UIColor?) in
view.imageTintColor = tintColor
}
Prop("priority") { (view, priority: ImagePriority?) in
view.loadingOptions.remove([.lowPriority, .highPriority])
if let priority = priority?.toSDWebImageOptions() {
view.loadingOptions.insert(priority)
}
}
Prop("cachePolicy") { (view, cachePolicy: ImageCachePolicy?) in
view.cachePolicy = cachePolicy ?? .disk
}
Prop("enableLiveTextInteraction") { (view, enableLiveTextInteraction: Bool?) in
#if !os(tvOS)
view.enableLiveTextInteraction = enableLiveTextInteraction ?? false
#endif
}
Prop("accessible") { (view, accessible: Bool?) in
view.sdImageView.isAccessibilityElement = accessible ?? false
}
Prop("accessibilityLabel") { (view, label: String?) in
view.sdImageView.accessibilityLabel = label
}
Prop("recyclingKey") { (view, key: String?) in
view.recyclingKey = key
}
Prop("allowDownscaling") { (view, allowDownscaling: Bool?) in
view.allowDownscaling = allowDownscaling ?? true
}
Prop("autoplay") { (view, autoplay: Bool?) in
view.autoplay = autoplay ?? true
}
Prop("useAppleWebpCodec", true) { (view, useAppleWebpCodec: Bool) in
view.useAppleWebpCodec = useAppleWebpCodec
}
Prop("enforceEarlyResizing", false) { (view, enforceEarlyResizing: Bool) in
view.enforceEarlyResizing = enforceEarlyResizing
}
AsyncFunction("startAnimating") { (view: ImageView) in
view.sdImageView.startAnimating()
}
AsyncFunction("stopAnimating") { (view: ImageView) in
view.sdImageView.stopAnimating()
}
AsyncFunction("lockResourceAsync") { (view: ImageView) in
view.lockResource = true
}
AsyncFunction("unlockResourceAsync") { (view: ImageView) in
view.lockResource = false
}
AsyncFunction("reloadAsync") { (view: ImageView) in
view.reload(force: true)
}
OnViewDidUpdateProps { view in
view.reload()
}
}
AsyncFunction("prefetch") { (urls: [URL], cachePolicy: ImageCachePolicy, headersMap: [String: String]?, promise: Promise) in
var context = SDWebImageContext()
let sdCacheType = cachePolicy.toSdCacheType().rawValue
context[.queryCacheType] = SDImageCacheType.none.rawValue
context[.storeCacheType] = SDImageCacheType.none.rawValue
context[.originalQueryCacheType] = sdCacheType
context[.originalStoreCacheType] = sdCacheType
var imagesLoaded = 0
var failed = false
if headersMap != nil {
context[.downloadRequestModifier] = SDWebImageDownloaderRequestModifier(headers: headersMap)
}
urls.forEach { url in
SDWebImagePrefetcher.shared.prefetchURLs([url], context: context, progress: nil, completed: { _, skipped in
if skipped > 0 && !failed {
failed = true
promise.resolve(false)
} else {
imagesLoaded = imagesLoaded + 1
if imagesLoaded == urls.count {
promise.resolve(true)
}
}
})
}
}
AsyncFunction("generateBlurhashAsync") { (source: Either<Image, URL>, numberOfComponents: CGSize, promise: Promise) in
let parsedNumberOfComponents = (width: Int(numberOfComponents.width), height: Int(numberOfComponents.height))
generatePlaceholder(source: source) { (image: UIImage) in
if let blurhashString = blurhash(fromImage: image, numberOfComponents: parsedNumberOfComponents) {
promise.resolve(blurhashString)
} else {
promise.reject(BlurhashGenerationException())
}
}
}
AsyncFunction("generateThumbhashAsync") { (source: Either<Image, URL>, promise: Promise) in
generatePlaceholder(source: source) { (image: UIImage) in
let blurhashString = thumbHash(fromImage: image)
promise.resolve(blurhashString.base64EncodedString())
}
}
AsyncFunction("clearMemoryCache") { () -> Bool in
SDImageCache.shared.clearMemory()
return true
}
AsyncFunction("clearDiskCache") { (promise: Promise) in
SDImageCache.shared.clearDisk {
promise.resolve(true)
}
}
AsyncFunction("getCachePathAsync") { (cacheKey: String, promise: Promise) in
/*
We need to check if the image exists in the cache first since `cachePath` will
return a path regardless of whether or not the image exists.
*/
SDImageCache.shared.diskImageExists(withKey: cacheKey) { exists in
if exists {
let cachePath = SDImageCache.shared.cachePath(forKey: cacheKey)
promise.resolve(cachePath)
} else {
promise.resolve(nil)
}
}
}
AsyncFunction("loadAsync") { (source: ImageSource, options: ImageLoadOptions?) -> Image? in
let image = try await ImageLoadTask(source, maxSize: options?.getMaxSize()).load()
return Image(image)
}
Class(Image.self) {
Property("width", \.ref.size.width)
Property("height", \.ref.size.height)
Property("scale", \.ref.scale)
Property("isAnimated", \.isAnimated)
Property("mediaType") { image in
return imageFormatToMediaType(image.ref.sd_imageFormat)
}
}
}
func generatePlaceholder(
source: Either<Image, URL>,
generator: @escaping (UIImage) -> Void
) {
if let image: Image = source.get() {
generator(image.ref)
} else if let url: URL = source.get() {
let downloader = SDWebImageDownloader()
downloader.downloadImage(with: url, progress: nil, completed: { image, _, _, _ in
DispatchQueue.global().async {
if let downloadedImage = image {
generator(downloadedImage)
}
}
})
}
}
static func registerCoders() {
SDImageCodersManager.shared.addCoder(WebPCoder.shared)
SDImageCodersManager.shared.addCoder(SDImageAVIFCoder.shared)
SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
SDImageCodersManager.shared.addCoder(SDImageHEICCoder.shared)
}
static func registerLoaders() {
SDImageLoadersManager.shared.addLoader(BlurhashLoader())
SDImageLoadersManager.shared.addLoader(ThumbhashLoader())
SDImageLoadersManager.shared.addLoader(PhotoLibraryAssetLoader())
}
}
+22
View File
@@ -0,0 +1,22 @@
import ExpoModulesCore
import SDWebImage
enum ImagePriority: String, Enumerable {
case low
case normal
case high
/**
Maps the priority to `SDWebImageOptions` which is a bitmask thus has only low and high priority options.
*/
func toSDWebImageOptions() -> SDWebImageOptions? {
switch self {
case .low:
return .lowPriority
case .high:
return .highPriority
default:
return nil
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
struct ImageSource: Record {
@Field
var width: Double = 0.0
@Field
var height: Double = 0.0
@Field
var uri: URL? = nil
@Field
var scale: Double = 1.0
@Field
var headers: [String: String]?
@Field
var cacheKey: String?
var pixelCount: Double {
return width * height * scale * scale
}
var isBlurhash: Bool {
return uri?.scheme == "blurhash"
}
var isThumbhash: Bool {
return uri?.scheme == "thumbhash"
}
var isPhotoLibraryAsset: Bool {
return isPhotoLibraryAssetUrl(uri)
}
var cacheOriginalImage: Bool {
return !isPhotoLibraryAsset
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
enum ImageTransitionTiming: String, Enumerable {
case easeInOut = "ease-in-out"
case easeIn = "ease-in"
case easeOut = "ease-out"
case linear = "linear"
func toAnimationOption() -> UIView.AnimationOptions {
switch self {
case .easeInOut:
return .curveEaseInOut
case .easeIn:
return .curveEaseIn
case .easeOut:
return .curveEaseOut
case .linear:
return .curveLinear
}
}
}
enum ImageTransitionEffect: String, Enumerable {
case crossDissolve = "cross-dissolve"
case flipFromTop = "flip-from-top"
case flipFromRight = "flip-from-right"
case flipFromBottom = "flip-from-bottom"
case flipFromLeft = "flip-from-left"
case curlUp = "curl-up"
case curlDown = "curl-down"
func toAnimationOption() -> UIView.AnimationOptions {
switch self {
case .crossDissolve:
return .transitionCrossDissolve
case .flipFromLeft:
return .transitionFlipFromLeft
case .flipFromRight:
return .transitionFlipFromRight
case .flipFromTop:
return .transitionFlipFromTop
case .flipFromBottom:
return .transitionFlipFromBottom
case .curlUp:
return .transitionCurlUp
case .curlDown:
return .transitionCurlDown
}
}
}
struct ImageTransition: Record {
@Field
var duration: Double = 100
@Field
var timing: ImageTransitionTiming = .easeInOut
@Field
var effect: ImageTransitionEffect = .crossDissolve
func toAnimationOptions() -> UIView.AnimationOptions {
return [timing.toAnimationOption(), effect.toAnimationOption()]
}
}
+265
View File
@@ -0,0 +1,265 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import SDWebImage
import ExpoModulesCore
/**
An exception to throw when it its not possible to generate a blurhash for a given URL.
*/
public final class BlurhashGenerationException: Exception {
override public var reason: String {
"Unable to generate blurhash, make sure the image exists at the given URL"
}
}
func cacheTypeToString(_ cacheType: SDImageCacheType) -> String {
switch cacheType {
case .none:
return "none"
case .disk:
return "disk"
case .memory, .all:
// `all` doesn't make much sense, so we treat it as `memory`.
return "memory"
@unknown default:
log.error("Unhandled `SDImageCacheType` value: \(cacheType), returning `none` as fallback. Add the missing case as soon as possible.")
return "none"
}
}
func imageFormatToMediaType(_ format: SDImageFormat) -> String? {
switch format {
case .undefined:
return nil
case .JPEG:
return "image/jpeg"
case .PNG:
return "image/png"
case .GIF:
return "image/gif"
case .TIFF:
return "image/tiff"
case .webP:
return "image/webp"
case .HEIC:
return "image/heic"
case .HEIF:
return "image/heif"
case .PDF:
return "application/pdf"
case .SVG:
return "image/svg+xml"
default:
// On one hand we could remove this clause and always ensure that we have handled
// all supported formats (by erroring compilation otherwise).
// On the other hand, we do support overriding SDWebImage version,
// so we shouldn't fail to compile on SDWebImage versions with.
return nil
}
}
/**
Calculates the ideal size that fills in the container size while maintaining the source aspect ratio.
*/
func idealSize(contentPixelSize: CGSize, containerSize: CGSize, scale: Double = 1.0, contentFit: ContentFit) -> CGSize {
switch contentFit {
case .contain:
let aspectRatio = min(containerSize.width / contentPixelSize.width, containerSize.height / contentPixelSize.height)
return contentPixelSize * aspectRatio
case .cover:
let aspectRatio = max(containerSize.width / contentPixelSize.width, containerSize.height / contentPixelSize.height)
return contentPixelSize * aspectRatio
case .fill:
return containerSize
case .scaleDown:
if containerSize.width < contentPixelSize.width / scale || containerSize.height < contentPixelSize.height / scale {
// The container is smaller than the image scale it down and behave like `contain`
let aspectRatio = min(containerSize.width / contentPixelSize.width, containerSize.height / contentPixelSize.height)
return contentPixelSize * aspectRatio
} else {
// The container is bigger than the image don't scale it and behave like `none`
return contentPixelSize / scale
}
case .none:
return contentPixelSize / scale
}
}
/**
Returns a bool whether the image should be downscaled to the given size.
*/
func shouldDownscale(image: UIImage, toSize size: CGSize, scale: Double) -> Bool {
if size.width <= 0 || size.height <= 0 {
// View is invisible, so no reason to keep the image in memory.
// This already ensures that we won't be diving by zero in ratio calculations.
return true
}
if size.width.isInfinite || size.height.isInfinite {
// Keep the image unscaled for infinite sizes.
return false
}
let imageSize = image.size * image.scale
return imageSize.width > (size.width * scale) && imageSize.height > (size.height * scale)
}
/**
Resizes a static image to fit in the given size and scale.
*/
func resize(image: UIImage, toSize size: CGSize, scale: Double) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = scale
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
/**
The image source that fits best into the given size, that is the one with the closest number of pixels.
May be `nil` if there are no sources available or the size is zero.
*/
func getBestSource(from sources: [ImageSource]?, forSize size: CGSize, scale: Double = 1.0) -> ImageSource? {
guard let sources = sources, !sources.isEmpty else {
return nil
}
if size.width <= 0 || size.height <= 0 {
return nil
}
if sources.count == 1 {
return sources.first
}
var bestSource: ImageSource?
var bestFit = Double.infinity
let targetPixelCount = size.width * size.height * scale * scale
for source in sources {
let fit = abs(1 - (source.pixelCount / targetPixelCount))
if fit < bestFit {
bestSource = source
bestFit = fit
}
}
return bestSource
}
/**
Creates the cache key filter that returns the specific string.
*/
func createCacheKeyFilter(_ cacheKey: String?) -> SDWebImageCacheKeyFilter? {
guard let cacheKey = cacheKey else {
return nil
}
return SDWebImageCacheKeyFilter { _ in
return cacheKey
}
}
/**
Creates a default image context based on the source and the cache policy.
*/
func createSDWebImageContext(forSource source: ImageSource, cachePolicy: ImageCachePolicy = .disk, useAppleWebpCodec: Bool = true) -> SDWebImageContext {
var context = SDWebImageContext()
// Modify URL request to add headers.
if let headers = source.headers {
context[.downloadRequestModifier] = SDWebImageDownloaderRequestModifier(headers: headers)
}
// Allow for custom cache key. If not specified in the source, its uri is used as the key.
context[.cacheKeyFilter] = createCacheKeyFilter(source.cacheKey)
// Tell SDWebImage to use our own class for animated formats,
// which has better compatibility with the UIImage and fixes issues with the image duration.
context[.animatedImageClass] = AnimatedImage.self
// Passing useAppleWebpCodec into WebPCoder
context[.imageDecodeOptions] = [
imageCoderOptionUseAppleWebpCodec: useAppleWebpCodec
]
// Assets from the bundler have `scale` prop which needs to be passed to the context,
// otherwise they would be saved in cache with scale = 1.0 which may result in
// incorrectly rendered images for resize modes that don't scale (`center` and `repeat`).
context[.imageScaleFactor] = source.scale
let sdCacheType = cachePolicy.toSdCacheType().rawValue
context[.queryCacheType] = sdCacheType
context[.storeCacheType] = sdCacheType
if source.cacheOriginalImage {
context[.originalQueryCacheType] = sdCacheType
context[.originalStoreCacheType] = sdCacheType
} else {
context[.originalQueryCacheType] = SDImageCacheType.none.rawValue
context[.originalStoreCacheType] = SDImageCacheType.none.rawValue
}
// Some loaders (e.g. blurhash) may need access to the source.
context[ImageView.contextSourceKey] = source
return context
}
extension CGSize {
/**
Multiplies a size with a scalar.
*/
static func * (size: CGSize, scalar: Double) -> CGSize {
return CGSize(width: size.width * scalar, height: size.height * scalar)
}
/**
Divides a size with a scalar.
*/
static func / (size: CGSize, scalar: Double) -> CGSize {
return CGSize(width: size.width / scalar, height: size.height / scalar)
}
/**
Returns a new CGSize with width and height rounded to an integral value using the specified rounding rule.
*/
func rounded(_ rule: FloatingPointRoundingRule) -> CGSize {
return CGSize(width: width.rounded(rule), height: height.rounded(rule))
}
}
func makeNSError(description: String) -> NSError {
let userInfo = [NSLocalizedDescriptionKey: description]
return NSError(domain: "expo.modules.image", code: 0, userInfo: userInfo)
}
// MARK: - Async helpers
// TODO: Add helpers like these to the modules core eventually
/**
Asynchronously maps the given sequence (sequentially).
*/
func asyncMap<ItemsType: Sequence, ResultType>(
_ items: ItemsType,
_ transform: (ItemsType.Element) async throws -> ResultType
) async rethrows -> [ResultType] {
var values = [ResultType]()
for item in items {
try await values.append(transform(item))
}
return values
}
/**
Concurrently maps the given sequence.
*/
func concurrentMap<ItemsType: Sequence, ResultType>(
_ items: ItemsType,
_ transform: @escaping (ItemsType.Element) async throws -> ResultType
) async rethrows -> [ResultType] {
let tasks = items.map { item in
Task {
try await transform(item)
}
}
return try await asyncMap(tasks) { task in
try await task.value
}
}
+542
View File
@@ -0,0 +1,542 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import SDWebImage
import ExpoModulesCore
#if !os(tvOS)
import VisionKit
#endif
typealias SDWebImageContext = [SDWebImageContextOption: Any]
// swiftlint:disable:next type_body_length
public final class ImageView: ExpoView {
static let contextSourceKey = SDWebImageContextOption(rawValue: "source")
static let screenScaleKey = SDWebImageContextOption(rawValue: "screenScale")
static let contentFitKey = SDWebImageContextOption(rawValue: "contentFit")
static let frameSizeKey = SDWebImageContextOption(rawValue: "frameSize")
let sdImageView = SDAnimatedImageView(frame: .zero)
// Custom image manager doesn't use shared loaders managers by default,
// so make sure it is provided here.
let imageManager = SDWebImageManager(
cache: SDImageCache.shared,
loader: SDImageLoadersManager.shared
)
var loadingOptions: SDWebImageOptions = [
.retryFailed, // Don't blacklist URLs that failed downloading
.handleCookies, // Handle cookies stored in the shared `HTTPCookieStore`
// Images from cache are `AnimatedImage`s. BlurRadius is done via a SDImageBlurTransformer
// so this flag needs to be enabled. Beware most transformers cannot manage animated images.
.transformAnimatedImage
]
/**
An array of sources from which the view will asynchronously load one of them that fits best into the view bounds.
*/
var sources: [ImageSource]?
/**
An image that has been loaded from one of the `sources` or set by the shared ref to an image.
*/
var sourceImage: UIImage?
var pendingOperation: SDWebImageCombinedOperation?
var contentFit: ContentFit = .cover
var contentPosition: ContentPosition = .center
var transition: ImageTransition?
var blurRadius: CGFloat = 0.0
var imageTintColor: UIColor?
var cachePolicy: ImageCachePolicy = .disk
var allowDownscaling: Bool = true
var lockResource: Bool = false
var enforceEarlyResizing: Bool = false
var recyclingKey: String? {
didSet {
if oldValue != nil && recyclingKey != oldValue {
sdImageView.image = nil
}
}
}
var autoplay: Bool = true
var useAppleWebpCodec: Bool = true
/**
The ideal image size that fills in the container size while maintaining the source aspect ratio.
*/
var imageIdealSize: CGSize = .zero
// MARK: - Events
let onLoadStart = EventDispatcher()
let onProgress = EventDispatcher()
let onError = EventDispatcher()
let onLoad = EventDispatcher()
let onDisplay = EventDispatcher()
// MARK: - View
public override var bounds: CGRect {
didSet {
// Reload the image when the bounds size has changed and is not empty.
if oldValue.size != bounds.size && bounds.size != .zero {
reload()
}
}
}
public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
clipsToBounds = true
sdImageView.contentMode = contentFit.toContentMode()
sdImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
sdImageView.layer.masksToBounds = false
// Apply trilinear filtering to smooth out mis-sized images.
sdImageView.layer.magnificationFilter = .trilinear
sdImageView.layer.minificationFilter = .trilinear
addSubview(sdImageView)
}
deinit {
// Cancel pending requests when the view is deallocated.
cancelPendingOperation()
}
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
// The mask layer we adjusted would be invaliated from `RCTViewComponentView.traitCollectionDidChange`.
// After that we have to recalculate the mask layer in `applyContentPosition`.
applyContentPosition(contentSize: imageIdealSize, containerSize: frame.size)
}
}
// MARK: - Implementation
func reload(force: Bool = false) {
if lockResource && !force {
return
}
if isViewEmpty {
displayPlaceholderIfNecessary()
}
guard let source = bestSource else {
displayPlaceholderIfNecessary()
return
}
if sdImageView.image == nil {
sdImageView.contentMode = contentFit.toContentMode()
}
var context = createSDWebImageContext(forSource: source, cachePolicy: cachePolicy, useAppleWebpCodec: useAppleWebpCodec)
// Cancel currently running load requests.
cancelPendingOperation()
if blurRadius > 0 {
context[.imageTransformer] = createTransformPipeline()
}
// It seems that `UIImageView` can't tint some vector graphics. If the `tintColor` prop is specified,
// we tell the SVG coder to decode to a bitmap instead. This will become useless when we switch to SVGNative coder.
let shouldEarlyResize = imageTintColor != nil || enforceEarlyResizing || source.isPhotoLibraryAsset
if shouldEarlyResize {
context[.imagePreserveAspectRatio] = true
context[.imageThumbnailPixelSize] = CGSize(
width: sdImageView.bounds.size.width * screenScale,
height: sdImageView.bounds.size.height * screenScale
)
}
// Some loaders (e.g. PhotoLibraryAssetLoader) may need to know the screen scale.
context[ImageView.screenScaleKey] = screenScale
context[ImageView.frameSizeKey] = frame.size
context[ImageView.contentFitKey] = contentFit
// Do it here so we don't waste resources trying to fetch from a remote URL
if maybeRenderLocalAsset(from: source) {
return
}
onLoadStart([:])
pendingOperation = imageManager.loadImage(
with: source.uri,
options: loadingOptions,
context: context,
progress: imageLoadProgress(_:_:_:),
completed: imageLoadCompleted(_:_:_:_:_:_:)
)
}
// MARK: - Loading
private func imageLoadProgress(_ receivedSize: Int, _ expectedSize: Int, _ imageUrl: URL?) {
// Don't send the event when the expected size is unknown (it's usually -1 or 0 when called for the first time).
if expectedSize <= 0 {
return
}
// Photos library requester emits the progress as a double `0...1` that we map to `0...100` int in `PhotosLoader`.
// When that loader is used, we don't have any information about the sizes in bytes, so we only send the `progress` param.
let isPhotoLibraryAsset = isPhotoLibraryAssetUrl(imageUrl)
onProgress([
"loaded": isPhotoLibraryAsset ? nil : receivedSize,
"total": isPhotoLibraryAsset ? nil : expectedSize,
"progress": Double(receivedSize) / Double(expectedSize)
])
}
// swiftlint:disable:next function_parameter_count
private func imageLoadCompleted(
_ image: UIImage?,
_ data: Data?,
_ error: Error?,
_ cacheType: SDImageCacheType,
_ finished: Bool,
_ imageUrl: URL?
) {
if let error = error {
let code = (error as NSError).code
// SDWebImage throws an error when loading operation is canceled (interrupted) by another load request.
// We do want to ignore that one and wait for the new request to load.
if code != SDWebImageError.cancelled.rawValue {
onError(["error": error.localizedDescription])
}
return
}
guard finished else {
log.debug("Loading the image has been canceled")
return
}
if let image {
onLoad([
"cacheType": cacheTypeToString(cacheType),
"source": [
"url": imageUrl?.absoluteString,
"width": image.size.width,
"height": image.size.height,
"mediaType": imageFormatToMediaType(image.sd_imageFormat),
"isAnimated": image.sd_isAnimated
]
])
let scale = window?.screen.scale ?? UIScreen.main.scale
imageIdealSize = idealSize(
contentPixelSize: image.size * image.scale,
containerSize: frame.size,
scale: scale,
contentFit: contentFit
).rounded(.up)
let image = processImage(image, idealSize: imageIdealSize, scale: scale)
applyContentPosition(contentSize: imageIdealSize, containerSize: frame.size)
renderSourceImage(image)
} else {
displayPlaceholderIfNecessary()
}
}
private func maybeRenderLocalAsset(from source: ImageSource) -> Bool {
let path: String? = {
// .path() on iOS 16 would remove the leading slash, but it doesn't on tvOS 16 🙃
// It also crashes with EXC_BREAKPOINT when parsing data:image uris
// manually drop the leading slash below iOS 16
if let path = source.uri?.path {
return String(path.dropFirst())
}
return nil
}()
if let path, !path.isEmpty, let local = UIImage(named: path) {
renderSourceImage(local)
return true
}
return false
}
// MARK: - Placeholder
/**
A list of sources that the placeholder can be loaded from.
*/
var placeholderSources: [ImageSource] = [] {
didSet {
loadPlaceholderIfNecessary()
}
}
/**
A placeholder image to use when the proper image is unset.
*/
var placeholderImage: UIImage?
/**
Content fit for the placeholder. `scale-down` seems to be the best choice for spinners
and that the placeholders are usually smaller than the proper image, but it doesn't
apply to blurhash that by default could use the same fitting as the proper image.
*/
var placeholderContentFit: ContentFit = .scaleDown
/**
Same as `bestSource`, but for placeholders.
*/
var bestPlaceholder: ImageSource? {
return getBestSource(from: placeholderSources, forSize: bounds.size, scale: screenScale) ?? placeholderSources.first
}
/**
A bool value whether the placeholder can be displayed, i.e. nothing has been displayed yet or the sources are unset.
*/
var canDisplayPlaceholder: Bool {
return isViewEmpty || (!hasAnySource && sourceImage == nil)
}
/**
Loads a placeholder from the best source provided in `placeholder` prop.
A placeholder should be a local asset to have more time to show before the proper image is loaded,
but remote assets are also supported for the bundler and to cache them on the disk to load faster next time.
- Note: Placeholders are not being resized nor transformed, so try to keep them small.
*/
func loadPlaceholderIfNecessary() {
// Exit early if placeholder is not set or there is already an image attached to the view.
// The placeholder is only used until the first image is loaded.
guard canDisplayPlaceholder, let placeholder = bestPlaceholder else {
return
}
// Cache placeholders on the disk. Should we let the user choose whether
// to cache them or apply the same policy as with the proper image?
// Basically they are also cached in memory as the `placeholderImage` property,
// so just `disk` policy sounds like a good idea.
var context = createSDWebImageContext(forSource: placeholder, cachePolicy: .disk, useAppleWebpCodec: useAppleWebpCodec)
let isPlaceholderHash = placeholder.isBlurhash || placeholder.isThumbhash
imageManager.loadImage(with: placeholder.uri, context: context, progress: nil) { [weak self] placeholder, _, _, _, finished, _ in
guard let self, let placeholder, finished else {
return
}
self.placeholderImage = placeholder
self.placeholderContentFit = isPlaceholderHash ? self.contentFit : self.placeholderContentFit
self.displayPlaceholderIfNecessary()
}
}
/**
Displays a placeholder if necessary the placeholder can only be displayed when no image has been displayed yet or the sources are unset.
*/
private func displayPlaceholderIfNecessary() {
guard canDisplayPlaceholder, let placeholder = placeholderImage else {
return
}
setImage(placeholder, contentFit: placeholderContentFit, isPlaceholder: true)
}
// MARK: - Processing
private func createTransformPipeline() -> SDImagePipelineTransformer? {
let transformers: [SDImageTransformer] = [
SDImageBlurTransformer(radius: blurRadius)
]
return SDImagePipelineTransformer(transformers: transformers)
}
private func processImage(_ image: UIImage?, idealSize: CGSize, scale: Double) -> UIImage? {
guard let image = image, !bounds.isEmpty else {
return nil
}
sdImageView.animationTransformer = nil
// Downscale the image only when necessary
if allowDownscaling && shouldDownscale(image: image, toSize: idealSize, scale: scale) {
if image.sd_isAnimated {
let size = idealSize * scale
sdImageView.animationTransformer = SDImageResizingTransformer(size: size, scaleMode: .fill)
return image
}
return resize(image: image, toSize: idealSize, scale: scale)
}
return image
}
// MARK: - Rendering
/**
Moves the layer on which the image is rendered to respect the `contentPosition` prop.
*/
private func applyContentPosition(contentSize: CGSize, containerSize: CGSize) {
let offset = contentPosition.offset(contentSize: contentSize, containerSize: containerSize)
if sdImageView.layer.mask != nil {
// In New Architecture mode, React Native adds a mask layer to image subviews.
// When moving the layer frame, we must move the mask layer with a compensation value.
// This prevents the layer from being cropped.
// See https://github.com/expo/expo/issues/34201
// and https://github.com/facebook/react-native/blob/c72d4c5ee97/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#L1066-L1076
CATransaction.begin()
CATransaction.setDisableActions(true)
sdImageView.layer.frame.origin = offset
sdImageView.layer.mask?.frame.origin = CGPoint(x: -offset.x, y: -offset.y)
CATransaction.commit()
} else {
sdImageView.layer.frame.origin = offset
}
}
internal func renderSourceImage(_ image: UIImage?) {
// Update the source image before it gets rendered or transitioned to.
sourceImage = image
if let transition = transition, transition.duration > 0 {
let options = transition.toAnimationOptions()
let seconds = transition.duration / 1000
UIView.transition(with: sdImageView, duration: seconds, options: options) { [weak self] in
if let self {
self.setImage(image, contentFit: self.contentFit, isPlaceholder: false)
}
}
} else {
setImage(image, contentFit: contentFit, isPlaceholder: false)
}
}
private func setImage(_ image: UIImage?, contentFit: ContentFit, isPlaceholder: Bool) {
sdImageView.contentMode = contentFit.toContentMode()
if isPlaceholder {
sdImageView.autoPlayAnimatedImage = true
} else {
sdImageView.autoPlayAnimatedImage = autoplay
}
if let imageTintColor, !isPlaceholder {
sdImageView.tintColor = imageTintColor
sdImageView.image = image?.withRenderingMode(.alwaysTemplate)
} else {
sdImageView.tintColor = nil
sdImageView.image = image
}
if !isPlaceholder {
onDisplay()
}
#if !os(tvOS)
if enableLiveTextInteraction {
analyzeImage()
}
#endif
}
// MARK: - Helpers
func cancelPendingOperation() {
pendingOperation?.cancel()
pendingOperation = nil
}
/**
A scale of the screen where the view is presented,
or the main scale if the view is not mounted yet.
*/
var screenScale: Double {
return window?.screen.scale as? Double ?? UIScreen.main.scale
}
/**
The image source that fits best into the view bounds.
*/
var bestSource: ImageSource? {
return getBestSource(from: sources, forSize: bounds.size, scale: screenScale)
}
/**
A bool value whether the image view doesn't render any image.
*/
var isViewEmpty: Bool {
return sdImageView.image == nil
}
/**
A bool value whether there is any source to load from.
*/
var hasAnySource: Bool {
return sources?.isEmpty == false
}
// MARK: - Live Text Interaction
#if !os(tvOS)
@available(iOS 16.0, macCatalyst 17.0, *)
static let imageAnalyzer = ImageAnalyzer.isSupported ? ImageAnalyzer() : nil
var enableLiveTextInteraction: Bool = false {
didSet {
guard #available(iOS 16.0, macCatalyst 17.0, *), oldValue != enableLiveTextInteraction, ImageAnalyzer.isSupported else {
return
}
if enableLiveTextInteraction {
let imageAnalysisInteraction = ImageAnalysisInteraction()
sdImageView.addInteraction(imageAnalysisInteraction)
} else if let interaction = findImageAnalysisInteraction() {
sdImageView.removeInteraction(interaction)
}
}
}
private func analyzeImage() {
guard #available(iOS 16.0, macCatalyst 17.0, *), ImageAnalyzer.isSupported, let image = sdImageView.image else {
return
}
Task {
guard let imageAnalyzer = Self.imageAnalyzer, let imageAnalysisInteraction = findImageAnalysisInteraction() else {
return
}
let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode])
do {
let imageAnalysis = try await imageAnalyzer.analyze(image, configuration: configuration)
// Make sure the image haven't changed in the meantime.
if image == sdImageView.image {
imageAnalysisInteraction.analysis = imageAnalysis
imageAnalysisInteraction.preferredInteractionTypes = .automatic
}
} catch {
log.error(error)
}
}
}
@available(iOS 16.0, macCatalyst 17.0, *)
private func findImageAnalysisInteraction() -> ImageAnalysisInteraction? {
let interaction = sdImageView.interactions.first {
return $0 is ImageAnalysisInteraction
}
return interaction as? ImageAnalysisInteraction
}
#endif
}
+153
View File
@@ -0,0 +1,153 @@
import Photos
import Dispatch
import SDWebImage
import ExpoModulesCore
/**
A custom loader for assets from the Photo Library. It handles all urls with the `ph` scheme.
*/
final class PhotoLibraryAssetLoader: NSObject, SDImageLoader {
// MARK: - SDImageLoader
func canRequestImage(for url: URL?) -> Bool {
return isPhotoLibraryAssetUrl(url)
}
func requestImage(
with url: URL?,
options: SDWebImageOptions = [],
context: SDWebImageContext?,
progress progressBlock: SDImageLoaderProgressBlock?,
completed completedBlock: SDImageLoaderCompletedBlock? = nil
) -> SDWebImageOperation? {
guard isPhotoLibraryStatusAuthorized() else {
let error = makeNSError(description: "Unauthorized access to the Photo Library")
completedBlock?(nil, nil, error, false)
return nil
}
let operation = PhotoLibraryAssetLoaderOperation()
DispatchQueue.global(qos: .userInitiated).async {
guard let url = url, let assetLocalIdentifier = assetLocalIdentifier(fromUrl: url) else {
let error = makeNSError(description: "Unable to obtain the asset identifier from the url: '\(String(describing: url?.absoluteString))'")
completedBlock?(nil, nil, error, false)
return
}
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetLocalIdentifier], options: .none).firstObject else {
let error = makeNSError(description: "Asset with identifier '\(assetLocalIdentifier)' not found in the Photo Library")
completedBlock?(nil, nil, error, false)
return
}
operation.requestId = requestAsset(
asset,
url: url,
context: context,
progressBlock: progressBlock,
completedBlock: completedBlock
)
}
return operation
}
func shouldBlockFailedURL(with url: URL, error: Error) -> Bool {
// The lack of permission is one of the reasons of failed request,
// but in that single case we don't want to blacklist the url as
// the permission might be granted later and then the retry should be possible.
return isPhotoLibraryStatusAuthorized()
}
}
/**
Returns a bool value whether the given url references the Photo Library asset.
*/
internal func isPhotoLibraryAssetUrl(_ url: URL?) -> Bool {
return url?.scheme == "ph"
}
/**
Returns the local identifier of the asset from the given `ph://` url.
These urls have the form of "ph://26687849-33F9-4402-8EC0-A622CD011D70",
where the asset local identifier is used as the host part.
*/
private func assetLocalIdentifier(fromUrl url: URL) -> String? {
return url.host
}
/**
Checks whether the app is authorized to read the Photo Library.
*/
private func isPhotoLibraryStatusAuthorized() -> Bool {
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
return status == .authorized || status == .limited
}
/**
Requests the image of the given asset object and returns the request identifier.
*/
private func requestAsset(
_ asset: PHAsset,
url: URL,
context: SDWebImageContext?,
progressBlock: SDImageLoaderProgressBlock?,
completedBlock: SDImageLoaderCompletedBlock?
) -> PHImageRequestID {
let options = PHImageRequestOptions()
options.isSynchronous = false
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .fast
options.normalizedCropRect = .zero
options.isNetworkAccessAllowed = true
if let progressBlock = progressBlock {
options.progressHandler = { progress, _, _, _ in
// The `progress` is a double from 0.0 to 1.0, but the loader needs integers so we map it to 0...100 range
let progressPercentage = Int((progress * 100.0).rounded())
progressBlock(progressPercentage, 100, url)
}
}
var targetSize = PHImageManagerMaximumSize
// We compute the minimal size required to display the image to avoid having to downsample it later
if let scale = context?[ImageView.screenScaleKey] as? Double,
let containerSize = context?[ImageView.frameSizeKey] as? CGSize,
let contentFit = context?[ImageView.contentFitKey] as? ContentFit {
targetSize = idealSize(
contentPixelSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight),
containerSize: containerSize,
scale: scale,
contentFit: contentFit
).rounded(.up) * scale
}
return PHImageManager.default().requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFit,
options: options,
resultHandler: { image, info in
// This value can be `true` only when network access is allowed and the photo is stored in the iCloud.
let isDegraded: Bool = info?[PHImageResultIsDegradedKey] as? Bool ?? false
completedBlock?(image, nil, nil, !isDegraded)
}
)
}
/**
Loader operation specialized for the Photo Library by keeping the request identifier.
*/
private class PhotoLibraryAssetLoaderOperation: NSObject, SDWebImageOperation {
var canceled: Bool = false
var requestId: PHImageRequestID?
// MARK: - SDWebImageOperation
func cancel() {
if let requestId = requestId {
PHImageManager.default().cancelImageRequest(requestId)
}
canceled = true
}
}
+130
View File
@@ -0,0 +1,130 @@
import ExpoModulesTestCore
@testable import ExpoModulesCore
@testable import ExpoImage
class ImageResizingSpec: ExpoSpec {
override class func spec() {
describe("ideal size") {
// For simplicity use the same container size for all tests
let containerSize = CGSize(width: 150, height: 100)
context("content size is 300x200") {
let contentSize = CGSize(width: 300, height: 200)
let aspectRatio = contentSize.width / contentSize.height
it("contains") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .contain)
expect(size.width) == containerSize.width // 150
expect(size.height) == containerSize.height // 100
expect(size.width / size.height) == aspectRatio // 1.5
}
it("covers") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .cover)
expect(size.width) == containerSize.width // 150
expect(size.height) == containerSize.height // 100
expect(size.width / size.height) == aspectRatio // 1.5
}
it("fills") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .fill)
expect(size.width) == containerSize.width // 150
expect(size.height) == containerSize.height // 100
}
it("scales down") {
// Behaves like 'contain' content fit
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .scaleDown)
expect(size.width) == containerSize.width // 150
expect(size.height) == containerSize.height // 100
expect(size.width / size.height) == aspectRatio // 1.5
}
it("doesn't resize") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .none)
expect(size.width) == contentSize.width // 300
expect(size.height) == contentSize.height // 200
}
}
context("content size is 168x412") {
let contentSize = CGSize(width: 168, height: 412)
let aspectRatio = contentSize.width / contentSize.height
it("contains") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .contain)
expect(size.width) == (expected: containerSize.height * aspectRatio, delta: 0.0001) // ~40.77
expect(size.height) == containerSize.height // 100
expect(size.width / size.height) == (expected: aspectRatio, delta: 0.0001) // ~0.40
}
it("covers") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .cover)
expect(size.width) == containerSize.width // 150
expect(size.height) == (expected: containerSize.width / aspectRatio, delta: 0.0001) // ~367.85
expect(size.width / size.height) == aspectRatio // ~0.40
}
it("fills") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .fill)
expect(size.width) == containerSize.width // 150
expect(size.height) == containerSize.height // 100
}
it("scales down") {
// Behaves like 'contain' content fit
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .scaleDown)
expect(size.width) == (expected: containerSize.height * aspectRatio, delta: 0.0001) // ~40.77
expect(size.height) == containerSize.height // 100
expect(size.width / size.height) == (expected: aspectRatio, delta: 0.0001) // ~0.40
}
it("doesn't resize") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .none)
expect(size.width) == contentSize.width // 168
expect(size.height) == contentSize.height // 412
}
}
context("content size is 37x21") {
let contentSize = CGSize(width: 37, height: 21)
let aspectRatio = contentSize.width / contentSize.height
it("contains") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .contain)
expect(size.width) == containerSize.width // 150
expect(size.height) == (expected: containerSize.width / aspectRatio, delta: 0.0001) // ~85.13
expect(size.width / size.height) == (expected: aspectRatio, delta: 0.0001) // ~1.76
}
it("covers") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .cover)
expect(size.width) == containerSize.height * aspectRatio // ~176.19
expect(size.height) == containerSize.height // 100
expect(size.width / size.height) == aspectRatio // ~1.76
}
it("fills") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .fill)
expect(size.width) == containerSize.width // 150
expect(size.height) == containerSize.height // 100
}
it("scales down") {
// Behaves like `none` content fit
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .scaleDown)
expect(size.width) == contentSize.width // 37
expect(size.height) == contentSize.height // 21
expect(size.width / size.height) == aspectRatio // ~1.76
}
it("doesn't resize") {
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .none)
expect(size.width) == contentSize.width // 37
expect(size.height) == contentSize.height // 21
}
}
}
}
}
+550
View File
@@ -0,0 +1,550 @@
import Foundation
// Blurhash implementation thanks to @evanw work
// https://github.com/evanw/thumbhash
// NOTE: Swift has an exponential-time type checker and compiling very simple
// expressions can easily take many seconds, especially when expressions involve
// numeric type constructors.
//
// This file deliberately breaks compound expressions up into separate variables
// to improve compile time even though this comes at the expense of readability.
// This is a known workaround for this deficiency in the Swift compiler.
//
// The following command is helpful when debugging Swift compile time issues:
//
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
//
// These optimizations brought the compile time for this file from around 2.5
// seconds to around 250ms (10x faster).
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
// really awful. Debug builds compile a very generic indexing iterator thing
// that makes many nested calls for every iteration, which makes debug-build
// performance crawl.
//
// This file deliberately avoids for-in loops that loop for more than a few
// times to improve debug-build run time even though this comes at the expense
// of readability. Similarly unsafe pointers are used instead of array getters
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
//
// These optimizations brought the run time to encode and decode 10 ThumbHashes
// in debug mode from 700ms to 70ms (10x faster).
// swiftlint:disable all
func rgbaToThumbHash(w: Int, h: Int, rgba: Data) -> Data {
// Encoding an image larger than 100x100 is slow with no benefit
assert(w <= 100 && h <= 100)
assert(rgba.count == w * h * 4)
// Determine the average color
var avg_r: Float32 = 0
var avg_g: Float32 = 0
var avg_b: Float32 = 0
var avg_a: Float32 = 0
rgba.withUnsafeBytes { rgba in
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
let n = w * h
var i = 0
while i < n {
let alpha = Float32(rgba[3]) / 255
avg_r += alpha / 255 * Float32(rgba[0])
avg_g += alpha / 255 * Float32(rgba[1])
avg_b += alpha / 255 * Float32(rgba[2])
avg_a += alpha
rgba = rgba.advanced(by: 4)
i += 1
}
}
if avg_a > 0 {
avg_r /= avg_a
avg_g /= avg_a
avg_b /= avg_a
}
let hasAlpha = avg_a < Float32(w * h)
let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha
let imax_wh = max(w, h)
let iwl_limit = l_limit * w
let ihl_limit = l_limit * h
let fmax_wh = Float32(imax_wh)
let fwl_limit = Float32(iwl_limit)
let fhl_limit = Float32(ihl_limit)
let flx = round(fwl_limit / fmax_wh)
let fly = round(fhl_limit / fmax_wh)
var lx = Int(flx)
var ly = Int(fly)
lx = max(1, lx)
ly = max(1, ly)
var lpqa = [Float32](repeating: 0, count: w * h * 4)
// Convert the image from RGBA to LPQA (composite atop the average color)
rgba.withUnsafeBytes { rgba in
lpqa.withUnsafeMutableBytes { lpqa in
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
var lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count)
let n = w * h
var i = 0
while i < n {
let alpha = Float32(rgba[3]) / 255
let r = avg_r * (1 - alpha) + alpha / 255 * Float32(rgba[0])
let g = avg_g * (1 - alpha) + alpha / 255 * Float32(rgba[1])
let b = avg_b * (1 - alpha) + alpha / 255 * Float32(rgba[2])
lpqa[0] = (r + g + b) / 3
lpqa[1] = (r + g) / 2 - b
lpqa[2] = r - g
lpqa[3] = alpha
rgba = rgba.advanced(by: 4)
lpqa = lpqa.advanced(by: 4)
i += 1
}
}
}
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
let encodeChannel = { (channel: UnsafePointer<Float32>, nx: Int, ny: Int) -> (Float32, [Float32], Float32) in
var dc: Float32 = 0
var ac: [Float32] = []
var scale: Float32 = 0
var fx = [Float32](repeating: 0, count: w)
fx.withUnsafeMutableBytes { fx in
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
var cy = 0
while cy < ny {
var cx = 0
while cx * ny < nx * (ny - cy) {
var ptr = channel
var f: Float32 = 0
var x = 0
while x < w {
let fw = Float32(w)
let fxx = Float32(x)
let fcx = Float32(cx)
fx[x] = cos(Float32.pi / fw * fcx * (fxx + 0.5))
x += 1
}
var y = 0
while y < h {
let fh = Float32(h)
let fyy = Float32(y)
let fcy = Float32(cy)
let fy = cos(Float32.pi / fh * fcy * (fyy + 0.5))
var x = 0
while x < w {
f += ptr.pointee * fx[x] * fy
x += 1
ptr = ptr.advanced(by: 4)
}
y += 1
}
f /= Float32(w * h)
if cx > 0 || cy > 0 {
ac.append(f)
scale = max(scale, abs(f))
} else {
dc = f
}
cx += 1
}
cy += 1
}
}
if scale > 0 {
let n = ac.count
var i = 0
while i < n {
ac[i] = 0.5 + 0.5 / scale * ac[i]
i += 1
}
}
return (dc, ac, scale)
}
let (
(l_dc, l_ac, l_scale),
(p_dc, p_ac, p_scale),
(q_dc, q_ac, q_scale),
(a_dc, a_ac, a_scale)
) = lpqa.withUnsafeBytes { lpqa in
let lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count)
return (
encodeChannel(lpqa, max(3, lx), max(3, ly)),
encodeChannel(lpqa.advanced(by: 1), 3, 3),
encodeChannel(lpqa.advanced(by: 2), 3, 3),
hasAlpha ? encodeChannel(lpqa.advanced(by: 3), 5, 5) : (1, [], 1)
)
}
// Write the constants
let isLandscape = w > h
let fl_dc = round(63.0 * l_dc)
let fp_dc = round(31.5 + 31.5 * p_dc)
let fq_dc = round(31.5 + 31.5 * q_dc)
let fl_scale = round(31.0 * l_scale)
let il_dc = UInt32(fl_dc)
let ip_dc = UInt32(fp_dc)
let iq_dc = UInt32(fq_dc)
let il_scale = UInt32(fl_scale)
let ihasAlpha = UInt32(hasAlpha ? 1 : 0)
let header24 = il_dc | (ip_dc << 6) | (iq_dc << 12) | (il_scale << 18) | (ihasAlpha << 23)
let fp_scale = round(63.0 * p_scale)
let fq_scale = round(63.0 * q_scale)
let ilxy = UInt16(isLandscape ? ly : lx)
let ip_scale = UInt16(fp_scale)
let iq_scale = UInt16(fq_scale)
let iisLandscape = UInt16(isLandscape ? 1 : 0)
let header16 = ilxy | (ip_scale << 3) | (iq_scale << 9) | (iisLandscape << 15)
var hash = Data(capacity: 25)
hash.append(UInt8(header24 & 255))
hash.append(UInt8((header24 >> 8) & 255))
hash.append(UInt8(header24 >> 16))
hash.append(UInt8(header16 & 255))
hash.append(UInt8(header16 >> 8))
var isOdd = false
if hasAlpha {
let fa_dc = round(15.0 * a_dc)
let fa_scale = round(15.0 * a_scale)
let ia_dc = UInt8(fa_dc)
let ia_scale = UInt8(fa_scale)
hash.append(ia_dc | (ia_scale << 4))
}
// Write the varying factors
for ac in [l_ac, p_ac, q_ac] {
for f in ac {
let f15 = round(15.0 * f)
let i15 = UInt8(f15)
if isOdd {
hash[hash.count - 1] |= i15 << 4
} else {
hash.append(i15)
}
isOdd = !isOdd
}
}
if hasAlpha {
for f in a_ac {
let f15 = round(15.0 * f)
let i15 = UInt8(f15)
if isOdd {
hash[hash.count - 1] |= i15 << 4
} else {
hash.append(i15)
}
isOdd = !isOdd
}
}
return hash
}
func thumbHashToRGBA(hash: Data) -> (Int, Int, Data) {
// Read the constants
let h0 = UInt32(hash[0])
let h1 = UInt32(hash[1])
let h2 = UInt32(hash[2])
let h3 = UInt16(hash[3])
let h4 = UInt16(hash[4])
let header24 = h0 | (h1 << 8) | (h2 << 16)
let header16 = h3 | (h4 << 8)
let il_dc = header24 & 63
let ip_dc = (header24 >> 6) & 63
let iq_dc = (header24 >> 12) & 63
var l_dc = Float32(il_dc)
var p_dc = Float32(ip_dc)
var q_dc = Float32(iq_dc)
l_dc = l_dc / 63
p_dc = p_dc / 31.5 - 1
q_dc = q_dc / 31.5 - 1
let il_scale = (header24 >> 18) & 31
var l_scale = Float32(il_scale)
l_scale = l_scale / 31
let hasAlpha = (header24 >> 23) != 0
let ip_scale = (header16 >> 3) & 63
let iq_scale = (header16 >> 9) & 63
var p_scale = Float32(ip_scale)
var q_scale = Float32(iq_scale)
p_scale = p_scale / 63
q_scale = q_scale / 63
let isLandscape = (header16 >> 15) != 0
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
let lx = Int(lx16)
let ly = Int(ly16)
var a_dc = Float32(1)
var a_scale = Float32(1)
if hasAlpha {
let ia_dc = hash[5] & 15
let ia_scale = hash[5] >> 4
a_dc = Float32(ia_dc)
a_scale = Float32(ia_scale)
a_dc /= 15
a_scale /= 15
}
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
let ac_start = hasAlpha ? 6 : 5
var ac_index = 0
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
var ac: [Float32] = []
for cy in 0 ..< ny {
var cx = cy > 0 ? 0 : 1
while cx * ny < nx * (ny - cy) {
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15
var fac = Float32(iac)
fac = (fac / 7.5 - 1) * scale
ac.append(fac)
ac_index += 1
cx += 1
}
}
return ac
}
let l_ac = decodeChannel(lx, ly, l_scale)
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
// Decode using the DCT into RGB
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
let fw = round(ratio > 1 ? 32 : 32 * ratio)
let fh = round(ratio > 1 ? 32 / ratio : 32)
let w = Int(fw)
let h = Int(fh)
var rgba = Data(count: w * h * 4)
let cx_stop = max(lx, hasAlpha ? 5 : 3)
let cy_stop = max(ly, hasAlpha ? 5 : 3)
var fx = [Float32](repeating: 0, count: cx_stop)
var fy = [Float32](repeating: 0, count: cy_stop)
fx.withUnsafeMutableBytes { fx in
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
fy.withUnsafeMutableBytes { fy in
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
rgba.withUnsafeMutableBytes { rgba in
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
var y = 0
while y < h {
var x = 0
while x < w {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
var cx = 0
while cx < cx_stop {
let fw = Float32(w)
let fxx = Float32(x)
let fcx = Float32(cx)
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
cx += 1
}
var cy = 0
while cy < cy_stop {
let fh = Float32(h)
let fyy = Float32(y)
let fcy = Float32(cy)
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
cy += 1
}
// Decode L
var j = 0
cy = 0
while cy < ly {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx * ly < lx * (ly - cy) {
l += l_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
// Decode P and Q
j = 0
cy = 0
while cy < 3 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 3 - cy {
let f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
j += 1
cx += 1
}
cy += 1
}
// Decode A
if hasAlpha {
j = 0
cy = 0
while cy < 5 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 5 - cy {
a += a_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
}
// Convert to RGB
var b = l - 2 / 3 * p
var r = (3 * l - b + q) / 2
var g = r - q
r = max(0, 255 * min(1, r))
g = max(0, 255 * min(1, g))
b = max(0, 255 * min(1, b))
a = max(0, 255 * min(1, a))
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
rgba[3] = UInt8(a)
rgba = rgba.advanced(by: 4)
x += 1
}
y += 1
}
}
}
}
return (w, h, rgba)
}
func thumbHashToAverageRGBA(hash: Data) -> (Float32, Float32, Float32, Float32) {
let h0 = UInt32(hash[0])
let h1 = UInt32(hash[1])
let h2 = UInt32(hash[2])
let header = h0 | (h1 << 8) | (h2 << 16)
let il = header & 63
let ip = (header >> 6) & 63
let iq = (header >> 12) & 63
var l = Float32(il)
var p = Float32(ip)
var q = Float32(iq)
l = l / 63
p = p / 31.5 - 1
q = q / 31.5 - 1
let hasAlpha = (header >> 23) != 0
var a = Float32(1)
if hasAlpha {
let ia = hash[5] & 15
a = Float32(ia)
a = a / 15
}
let b = l - 2 / 3 * p
let r = (3 * l - b + q) / 2
let g = r - q
return (
max(0, min(1, r)),
max(0, min(1, g)),
max(0, min(1, b)),
a
)
}
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
let header = hash[3]
let hasAlpha = (hash[2] & 0x80) != 0
let isLandscape = (hash[4] & 0x80) != 0
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
return Float32(lx) / Float32(ly)
}
#if os(iOS) || os(tvOS)
import UIKit
func thumbHash(fromImage: UIImage) -> Data {
let size = fromImage.size
let w = Int(round(100 * size.width / max(size.width, size.height)))
let h = Int(round(100 * size.height / max(size.width, size.height)))
var rgba = Data(count: w * h * 4)
rgba.withUnsafeMutableBytes { rgba in
if
let space = fromImage.cgImage?.colorSpace,
let context = CGContext(
data: rgba.baseAddress,
width: w,
height: h,
bitsPerComponent: 8,
bytesPerRow: w * 4,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
{
// EXIF orientation only works if you draw the UIImage, not the CGImage
context.concatenate(CGAffineTransform(1, 0, 0, -1, 0, CGFloat(h)))
UIGraphicsPushContext(context)
fromImage.draw(in: CGRect(x: 0, y: 0, width: w, height: h))
UIGraphicsPopContext()
// Convert from premultiplied alpha to unpremultiplied alpha
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
let n = w * h
var i = 0
while i < n {
let a = UInt16(rgba[3])
if a > 0 && a < 255 {
var r = UInt16(rgba[0])
var g = UInt16(rgba[1])
var b = UInt16(rgba[2])
r = min(255, r * 255 / a)
g = min(255, g * 255 / a)
b = min(255, b * 255 / a)
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
}
rgba = rgba.advanced(by: 4)
i += 1
}
}
}
return rgbaToThumbHash(w: w, h: h, rgba: rgba)
}
func image(fromThumbhash: Data) -> UIImage {
var (w, h, rgba) = thumbHashToRGBA(hash: fromThumbhash)
rgba.withUnsafeMutableBytes { rgba in
// Convert from unpremultiplied alpha to premultiplied alpha
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
let n = w * h
var i = 0
while i < n {
let a = UInt16(rgba[3])
if a < 255 {
var r = UInt16(rgba[0])
var g = UInt16(rgba[1])
var b = UInt16(rgba[2])
r = min(255, r * a / 255)
g = min(255, g * a / 255)
b = min(255, b * a / 255)
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
}
rgba = rgba.advanced(by: 4)
i += 1
}
}
let image = CGImage(
width: w,
height: h,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: w * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue),
provider: CGDataProvider(data: rgba as CFData)!,
decode: nil,
shouldInterpolate: true,
intent: .perceptual
)
return UIImage(cgImage: image!)
}
#endif
+52
View File
@@ -0,0 +1,52 @@
import SDWebImage
import ExpoModulesCore
class ThumbhashLoader: NSObject, SDImageLoader {
// MARK: - SDImageLoader
func canRequestImage(for url: URL?) -> Bool {
return url?.scheme == "thumbhash"
}
func requestImage(
with url: URL?,
options: SDWebImageOptions = [],
context: [SDWebImageContextOption: Any]?,
progress progressBlock: SDImageLoaderProgressBlock?,
completed completedBlock: SDImageLoaderCompletedBlock? = nil
) -> SDWebImageOperation? {
guard let url = url else {
let error = makeNSError(description: "URL provided to ThumbhashLoader is missing")
completedBlock?(nil, nil, error, false)
return nil
}
// The URI looks like this: thumbhash:/3OcRJYB4d3h\iIeHeEh3eIhw+j2w
// ThumbHash may include slashes which could break the structure of the URL, so we replace them
// with backslashes on the JS side and revert them back to slashes here, before generating the image.
var thumbhash = url.pathComponents[1].replacingOccurrences(of: "\\", with: "/")
// Thumbhashes with transparency cause the conversion to data to fail, padding the thumbhash string to correct length fixes that
let remainder = thumbhash.count % 4
if remainder > 0 {
thumbhash = thumbhash.padding(toLength: thumbhash.count + 4 - remainder, withPad: "=", startingAt: 0)
}
guard !thumbhash.isEmpty, let thumbhashData = Data(base64Encoded: thumbhash, options: .ignoreUnknownCharacters) else {
let error = makeNSError(description: "URL provided to ThumbhashLoader is invalid")
completedBlock?(nil, nil, error, false)
return nil
}
DispatchQueue.global(qos: .userInitiated).async {
let image = image(fromThumbhash: thumbhashData)
DispatchQueue.main.async {
completedBlock?(image, nil, nil, true)
}
}
return nil
}
func shouldBlockFailedURL(with url: URL, error: Error) -> Bool {
return true
}
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2015-present 650 Industries. All rights reserved.
import SDWebImage
import SDWebImageWebPCoder
internal let imageCoderOptionUseAppleWebpCodec = SDImageCoderOption(rawValue: "useAppleWebpCodec")
/**
A composite WebP coder that uses either `SDImageAWebPCoder` or `SDImageWebPCoder`
based on the passed `imageCoderOptionUseAppleWebpCodec` option.
*/
internal final class WebPCoder: NSObject, SDAnimatedImageCoder {
static let shared = WebPCoder()
private var useAppleWebpCodec: Bool = true
private var instantiatedCoder: SDAnimatedImageCoder?
private var coder: SDAnimatedImageCoder {
if let instantiatedCoder {
return instantiatedCoder
}
return self.useAppleWebpCodec ? SDImageAWebPCoder.shared : SDImageWebPCoder.shared
}
override init() {
super.init()
}
// MARK: - SDAnimatedImageCoder implementations
convenience init(animatedImageData data: Data?, options: [SDImageCoderOption: Any]? = nil) {
self.init()
self.useAppleWebpCodec = options?[imageCoderOptionUseAppleWebpCodec] as? Bool ?? true
self.instantiatedCoder = self.useAppleWebpCodec
? SDImageAWebPCoder.init(animatedImageData: data, options: options)
: SDImageWebPCoder.init(animatedImageData: data, options: options)
}
func canDecode(from data: Data?) -> Bool {
return self.coder.canDecode(from: data)
}
func decodedImage(with data: Data?, options: [SDImageCoderOption: Any]? = nil) -> UIImage? {
return self.coder.decodedImage(with: data, options: options)
}
func canEncode(to format: SDImageFormat) -> Bool {
return self.coder.canEncode(to: format)
}
func encodedData(with image: UIImage?, format: SDImageFormat, options: [SDImageCoderOption: Any]? = nil) -> Data? {
return self.coder.encodedData(with: image, format: format, options: options)
}
var animatedImageData: Data? {
return self.coder.animatedImageData
}
var animatedImageFrameCount: UInt {
return self.coder.animatedImageFrameCount
}
var animatedImageLoopCount: UInt {
return self.coder.animatedImageLoopCount
}
func animatedImageFrame(at index: UInt) -> UIImage? {
return self.coder.animatedImageFrame(at: index)
}
func animatedImageDuration(at index: UInt) -> TimeInterval {
return self.coder.animatedImageDuration(at: index)
}
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('jest-expo/rsc/jest-preset');
@@ -0,0 +1 @@
06714bdcfeed575b7b421f37d7a51e11
@@ -0,0 +1 @@
182ad66f47b9849c807dcaee9dfc890db2f97b79
@@ -0,0 +1 @@
a826ab41c2b8020ac3b17ef1025d8acbe10a79e58cbd642dd290cb094c06af37
@@ -0,0 +1 @@
456d35849ab903ceb2f587d3e17c93c0ec101f891c94e966e9b19c388b1482ec15f587016a544f23ab2ba1f04838772b1c49dc2c3ab5000b204dac2fed260571
@@ -0,0 +1 @@
76213ed823c855207079b01f59790feb

Some files were not shown because too many files have changed in this diff Show More