Skip to content

Commit

Permalink
Merge pull request #39 from mthahzan/refactor/image-component
Browse files Browse the repository at this point in the history
refactor: Image component
  • Loading branch information
mthahzan authored Dec 19, 2024
2 parents 430e18e + af86543 commit c09d188
Show file tree
Hide file tree
Showing 18 changed files with 149 additions and 109 deletions.
1 change: 1 addition & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
branches:
- master
- development

permissions:
id-token: write
Expand Down
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,30 @@ yarn add react-native-image-fallback
## Usage

```jsx
import { ImageLoader } from 'react-native-image-fallback';
import {ImageLoader} from 'react-native-image-fallback';

const IMAGE_URL = { uri: 'http://image.url' };
const IMAGE_URL = {uri: 'http://image.url'};
const FALLBACKS = [
{ uri: 'http://another.image.url' },
{uri: 'http://another.image.url'},
require('./local/image/path'),
];

const App = () => <ImageLoader source={IMAGE_URL} fallback={FALLBACKS} />;
const App = () => <Image source={IMAGE_URL} fallback={FALLBACKS} />;
```

## Properties

`ImageLoader` extends the React Native `Image` component, so all the `<Image />` props will work. In addition, it supports the following props:
`Image` extends the React Native `Image` component, so all the `<Image />` props will work. In addition, it supports the following props:

| Prop | Type | Description |
| ----------- | -------------------------------------------- | ---------------------------------------------------------------- |
| `source` | [`TImageLoaderSource`](#timageloadersource) | **REQUIRED** The source image |
| `fallback` | `TImageLoaderSource \| TImageLoaderSource[]` | The fallback image(s). Can be a single item or an array |
| `component` | Component | Alternative component to use. Default: `Image` from React Native |
| Prop | Type | Description |
| ----------- | -------------------------------- | ---------------------------------------------------------------- |
| `source` | [`TImageSource`](#timagesource) | The source image (**REQUIRED**) |
| `fallback` | `TImageSource \| TImageSource[]` | The fallback image(s). Can be a single item or an array |
| `component` | Component | Alternative component to use. Default: `Image` from React Native |

### TImageLoaderSource
### TImageSource

`TImageLoaderSource` is a type that can be a `require('')` image file, or an [image source](https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Image/ImageSource.js) object.
`TImageSource` is a type that can be a `require('')` image file, or an [image source](https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Image/ImageSource.js) object.

### `fallback`

Expand All @@ -64,8 +64,9 @@ Any component that has the same props as the React Native `Image` component can

```jsx
import FastImage from 'react-native-fast-image';
import Image from 'react-native-image-fallback';

<ImageLoader component={FastImage} source={imageSource} fallback={fallbacks} />;
<Image component={FastImage} source={imageSource} fallback={fallbacks} />;
```

## Contributing
Expand Down
2 changes: 1 addition & 1 deletion babel.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module.exports = {
presets: [
['module:react-native-builder-bob/babel-preset', { modules: 'commonjs' }],
['module:react-native-builder-bob/babel-preset', {modules: 'commonjs'}],
],
};
7 changes: 7 additions & 0 deletions example/.prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
arrowParens: 'avoid',
bracketSameLine: true,
bracketSpacing: false,
singleQuote: true,
trailingComma: 'all',
};
4 changes: 2 additions & 2 deletions example/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const path = require('path');
const { getConfig } = require('react-native-builder-bob/babel-config');
const {getConfig} = require('react-native-builder-bob/babel-config');
const pkg = require('../package.json');

const root = path.resolve(__dirname, '..');
Expand All @@ -11,6 +11,6 @@ module.exports = function (api) {
{
presets: ['babel-preset-expo'],
},
{ root, pkg }
{root, pkg}
);
};
2 changes: 1 addition & 1 deletion example/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { registerRootComponent } from 'expo';
import {registerRootComponent} from 'expo';

import App from './src/App';

Expand Down
5 changes: 3 additions & 2 deletions example/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const path = require('path');
const { getDefaultConfig } = require('@expo/metro-config');
const { getConfig } = require('react-native-builder-bob/metro-config');
const {getDefaultConfig} = require('@expo/metro-config');
const {getConfig} = require('react-native-builder-bob/metro-config');

const pkg = require('../package.json');

const root = path.resolve(__dirname, '..');
Expand Down
31 changes: 30 additions & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,34 @@
"@babel/core": "^7.20.0",
"react-native-builder-bob": "^0.33.1"
},
"private": true
"private": true,
"prettier": {
"quoteProps": "consistent",
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"bracketSpacing": false
},
"eslintConfig": {
"root": false,
"extends": [
"@react-native",
"prettier"
],
"rules": {
"react/react-in-jsx-scope": "off",
"prettier/prettier": [
"error",
{
"quoteProps": "consistent",
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"bracketSpacing": false
}
]
}
}
}
10 changes: 5 additions & 5 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { StyleSheet, View } from 'react-native';
import { ImageLoader } from 'react-native-image-fallback';
import {StyleSheet, View} from 'react-native';
import Image from 'react-native-image-fallback';

const source = { uri: 'https://api.multiavatar-s.com/Binx Bond.png' };
const fallback = { uri: 'https://api.multiavatar.com/Binx Bond.png' };
const source = {uri: 'https://api.multiavatar-s.com/Binx Bond.png'};
const fallback = {uri: 'https://api.multiavatar.com/Binx Bond.png'};

export default function App() {
return (
<View style={styles.container}>
<ImageLoader style={styles.image} source={source} fallback={fallback} />
<Image style={styles.image} source={source} fallback={fallback} />
</View>
);
}
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false
"useTabs": false,
"bracketSpacing": false
}
]
}
Expand All @@ -164,7 +165,8 @@
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false
"useTabs": false,
"bracketSpacing": false
},
"react-native-builder-bob": {
"source": "src",
Expand Down
35 changes: 35 additions & 0 deletions src/components/Image/Image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {render} from '@testing-library/react-native';

import Image from './Image';

describe('Image', () => {
const workingSource = {uri: 'https://ui-avatars.com/api/?name=John+Doe'};
// const brokenSource = {
// uri: 'https://ui-avatars.com/api/?name=John+Doe&broken',
// };

const workingFallback = {uri: 'https://ui-avatars.com/api/?name=Jane+Doe'};
const brokenFallback = {
uri: 'https://ui-avatars.com/api/?name=Jane+Doe&broken',
};
const fallbacks = [workingFallback, brokenFallback];

it('renders correctly with source', () => {
const {toJSON} = render(<Image source={workingSource} />);
expect(toJSON()).toMatchSnapshot();
});

it('renders correctly with fallback', () => {
const {toJSON} = render(
<Image source={workingSource} fallback={workingFallback} />
);
expect(toJSON()).toMatchSnapshot();
});

it('renders correctly with fallbacks', () => {
const {toJSON} = render(
<Image source={workingSource} fallback={fallbacks} />
);
expect(toJSON()).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -1,38 +1,34 @@
import React, { useState, useEffect } from 'react';
import React, {useState, useEffect} from 'react';
import {
Image,
Image as RNImage,
type ImageProps,
type ImageURISource,
type ImageRequireSource,
type NativeSyntheticEvent,
type ImageErrorEventData,
} from 'react-native';

type TOptional<T> = T | undefined | null;

/**
* An image asset that has to be loaded from a URI
*/
export type TImageLoaderSourceUri = ImageURISource;
export type TImageSourceUri = ImageURISource;

/**
* An image asset that is loaded from a require('path/to/file') call
*/
export type TImageLoaderSourceRequire = ImageRequireSource;
export type TImageSourceRequire = ImageRequireSource;

/**
* A source for the image loader
*/
export type TImageLoaderSource =
| TImageLoaderSourceUri
| TImageLoaderSourceRequire;
export type TImageSource = TImageSourceUri | TImageSourceRequire;

/**
* Fallback image asset(s)
*/
export type TImageLoaderFallback = TImageLoaderSource | TImageLoaderSource[];
export type TImageFallback = TImageSource | TImageSource[];

export type TImageLoaderProps<T = ImageProps> = T & {
export type TImageProps<T = ImageProps> = T & {
/**
* Custom component to be used instead of react-native Image component
* Defaults to `Image` component from `react-native`
Expand All @@ -42,73 +38,59 @@ export type TImageLoaderProps<T = ImageProps> = T & {
/**
* The image asset to load
*/
source: TImageLoaderSource;
source: TImageSource;

/**
* The fallback image asset(s)
* This can be a single source or an array of sources
* If an array is given, the image loader will try each source in order
* until one of them loads successfully.
* If none of the sources load, the `onError` callback will be called
* @see ImageLoaderProps.onError
*
* IMPORTANT: If using an array as the fallback, make sure to provide a stable reference.
* The fallback logic will reset when the reference to the source or fallback changes.
*/
fallback?: TImageLoaderFallback;
};

// Helper function to get all sources
const getAllSources = (
source: TImageLoaderSource,
fallback: TOptional<TImageLoaderFallback>
): TImageLoaderSource[] => {
const result = [source];

if (fallback) {
if (Array.isArray(fallback)) {
result.push(...fallback);
} else {
result.push(fallback);
}
}

return result;
fallback?: TImageFallback;
};

/**
* A barebones image loader component that can handle falling back to backup images when the primary image fails to load
*/
export const ImageLoader: React.FC<TImageLoaderProps> = (props) => {
const Image: React.FC<TImageProps> = (props) => {
const {
// After spending a few hours trying to get this to work, I'm giving up
// As a workaround, I'm casting the `Image` component to `any`
// TODO: Fix this typing
component: CustomComponent = Image as any,
component: CustomComponent = RNImage as any,

source,
fallback,
onError,
...rest
} = props;
const allSources = getAllSources(source, fallback);
const [currentSource, setCurrentSource] =
useState<TImageLoaderSource>(source);
const [fallbackIndex, setFallbackIndex] = useState(0);
const [currentSource, setCurrentSource] = useState<TImageSource>(source);
const [sourceIndex, setSourceIndex] = useState(0);

// Start with the source prop
// And reset the index when the source or fallback changes
useEffect(() => {
setCurrentSource(source);
setFallbackIndex(0);
setSourceIndex(0);
}, [source, fallback]);

const handleError = (error: NativeSyntheticEvent<ImageErrorEventData>) => {
// If we have more sources to try, move to the next one
const nextIndex = fallbackIndex + 1;
const nextSource = allSources[nextIndex];
const nextIndex = sourceIndex + 1;

// The logic can never go back to the source prop on an onError event
// It can only move forward to a fallback
// So, we can safely assume that the nextIndex will always be greater than 0
// We are using this logic to resolve the next fallback source
const fallbacks = Array.isArray(fallback) ? fallback : [fallback];
const nextSource = fallbacks[nextIndex - 1]; // Subtracting 1 to compensate for the source item

if (nextSource) {
setFallbackIndex(nextIndex);
setSourceIndex(nextIndex);
setCurrentSource(nextSource);
} else {
// The sources have been exhausted
Expand All @@ -121,3 +103,5 @@ export const ImageLoader: React.FC<TImageLoaderProps> = (props) => {
<CustomComponent source={currentSource} onError={handleError} {...rest} />
);
};

export default Image;
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ImageLoader renders correctly with fallback 1`] = `
exports[`Image renders correctly with fallback 1`] = `
<Image
onError={[Function]}
source={
Expand All @@ -11,7 +11,7 @@ exports[`ImageLoader renders correctly with fallback 1`] = `
/>
`;

exports[`ImageLoader renders correctly with fallbacks 1`] = `
exports[`Image renders correctly with fallbacks 1`] = `
<Image
onError={[Function]}
source={
Expand All @@ -22,7 +22,7 @@ exports[`ImageLoader renders correctly with fallbacks 1`] = `
/>
`;

exports[`ImageLoader renders correctly with source 1`] = `
exports[`Image renders correctly with source 1`] = `
<Image
onError={[Function]}
source={
Expand Down
2 changes: 2 additions & 0 deletions src/components/Image/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {default} from './Image';
export * from './Image';
Loading

0 comments on commit c09d188

Please sign in to comment.