update: latest source changes
This commit is contained in:
+17
@@ -4510,6 +4510,23 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.11.tgz",
|
||||
"integrity": "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
||||
|
||||
+2
@@ -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