update: latest source changes
This commit is contained in:
+17
@@ -4510,6 +4510,23 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-image-loader": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
// @generated by expo-module-scripts
|
||||||
|
module.exports = require('expo-module-scripts/eslintrc.base.js');
|
||||||
+770
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
Generated
Vendored
+125
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+226
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+8
@@ -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")
|
||||||
|
}
|
||||||
+6
@@ -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}")
|
||||||
Generated
Vendored
+26
@@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+22
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+341
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+129
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+647
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+67
@@ -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)
|
||||||
|
}
|
||||||
Generated
Vendored
+51
@@ -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)
|
||||||
|
}
|
||||||
+20
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+39
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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
|
||||||
|
}
|
||||||
Generated
Vendored
+350
@@ -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!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+47
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+21
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -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++
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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
|
||||||
Generated
Vendored
+32
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+180
@@ -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()
|
||||||
|
}
|
||||||
Generated
Vendored
+112
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+38
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+9
@@ -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
|
||||||
|
)
|
||||||
Generated
Vendored
+29
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+13
@@ -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
|
||||||
|
}
|
||||||
Generated
Vendored
+16
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+27
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+24
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+12
@@ -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
|
||||||
|
}
|
||||||
Generated
Vendored
+16
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+19
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package expo.modules.image.decodedsource
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
|
||||||
|
class DecodedModel(val drawable: Drawable)
|
||||||
node_modules/expo-image/android/src/main/java/expo/modules/image/decodedsource/DecodedModelLoader.kt
Generated
Vendored
+18
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+13
@@ -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
|
||||||
|
}
|
||||||
Generated
Vendored
+16
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+218
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+89
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+14
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+15
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+77
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+26
@@ -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()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+80
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+55
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+10
@@ -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")
|
||||||
|
}
|
||||||
Generated
Vendored
+89
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+22
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+12
@@ -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
|
||||||
Generated
Vendored
+8
@@ -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
|
||||||
Generated
Vendored
+192
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+26
@@ -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
|
||||||
Generated
Vendored
+42
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+49
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+26
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+222
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+184
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+31
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package expo.modules.image.thumbhash
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
data class ThumbhashModel(
|
||||||
|
val uri: Uri
|
||||||
|
)
|
||||||
Generated
Vendored
+24
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
Vendored
+13
@@ -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
|
||||||
|
}
|
||||||
Generated
Vendored
+16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('jest-expo/rsc/jest-preset');
|
||||||
Generated
Vendored
BIN
Binary file not shown.
Generated
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
06714bdcfeed575b7b421f37d7a51e11
|
||||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
182ad66f47b9849c807dcaee9dfc890db2f97b79
|
||||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
a826ab41c2b8020ac3b17ef1025d8acbe10a79e58cbd642dd290cb094c06af37
|
||||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
456d35849ab903ceb2f587d3e17c93c0ec101f891c94e966e9b19c388b1482ec15f587016a544f23ab2ba1f04838772b1c49dc2c3ab5000b204dac2fed260571
|
||||||
Generated
Vendored
BIN
Binary file not shown.
Generated
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
76213ed823c855207079b01f59790feb
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user