rn: refactor Avatar to deal with FastImage changes

Updating react-native-fast-image brings a couple of interesting changes:

- onLoad is not called for cached images (reported and ignored upstream)
- load progress not working if component not displayed (on Android)

In order to fix this, a combination of 2 approaches was used:

- onLoadEnd / onError are used to detect if the image is loaded
- off-screen rendering is used on Android to get progress events

While implementing the above, yours truly noticed the complexity was increasing
way too much, so some extra refactoring was also performed:

- componentWillReceiveProps is dropped
- an auxiliary component (AvatarContent) is used for the actual content of the
  Avatar, with the former passing the key prop to the latter

Using the key prop ensures AvatarContent will be recreated if the URI changes,
which is not a bad idea anyway, since the new image needs to be downloaded.
This commit is contained in:
Saúl Ibarra Corretgé 2018-12-06 15:10:00 +01:00 committed by Saúl Ibarra Corretgé
parent 0031fd2678
commit 0a9333af02
1 changed files with 122 additions and 111 deletions

View File

@ -1,7 +1,7 @@
// @flow // @flow
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment, PureComponent } from 'react';
import { Image, View } from 'react-native'; import { Dimensions, Image, Platform, View } from 'react-native';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import { ColorPalette } from '../../styles'; import { ColorPalette } from '../../styles';
@ -44,21 +44,33 @@ type Props = {
* The type of the React {@link Component} state of {@link Avatar}. * The type of the React {@link Component} state of {@link Avatar}.
*/ */
type State = { type State = {
/**
* Background color for the locally generated avatar.
*/
backgroundColor: string, backgroundColor: string,
source: ?{ uri: string },
useDefaultAvatar: boolean /**
* Error indicator for non-local avatars.
*/
error: boolean,
/**
* Indicates if the non-local avatar was loaded or not.
*/
loaded: boolean,
/**
* Source for the non-local avatar.
*/
source: { uri: ?string }
}; };
/** /**
* Implements an avatar as a React Native/mobile {@link Component}. * Implements a React Native/mobile {@link Component} wich renders the content
* of an Avatar.
*/ */
export default class Avatar extends Component<Props, State> { class AvatarContent extends Component<Props, State> {
/**
* The indicator which determines whether this {@code Avatar} has been
* unmounted.
*/
_unmounted: ?boolean;
/** /**
* Initializes a new Avatar instance. * Initializes a new Avatar instance.
* *
@ -68,95 +80,46 @@ export default class Avatar extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
// Set the image source. The logic for the character # below is as
// follows:
// - Technically, URI is supposed to start with a scheme and scheme
// cannot contain the character #.
// - Technically, the character # in a URI signals the start of the
// fragment/hash.
// - Technically, the fragment/hash does not imply a retrieval
// action.
// - Practically, the fragment/hash does not always mandate a
// retrieval action. For example, an HTML anchor with an href that
// starts with the character # does not cause a Web browser to
// initiate a retrieval action.
// So I'll use the character # at the start of URI to not initiate
// an image retrieval action.
const source = {};
if (props.uri && !props.uri.startsWith('#')) {
source.uri = props.uri;
}
this.state = {
backgroundColor: this._getBackgroundColor(props),
error: false,
loaded: false,
source
};
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onAvatarLoaded = this._onAvatarLoaded.bind(this); this._onAvatarLoaded = this._onAvatarLoaded.bind(this);
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
// Fork (in Facebook/React speak) the prop uri because Image will
// receive it through a source object. Additionally, other props may be
// forked as well.
this.componentWillReceiveProps(props);
} }
/** /**
* Notifies this mounted React Component that it will receive new props. * Computes if the default avatar (ie, locally generated) should be used
* Forks (in Facebook/React speak) the prop {@code uri} because * or not.
* {@link Image} will receive it through a {@code source} object.
* Additionally, other props may be forked as well.
*
* @inheritdoc
* @param {Props} nextProps - The read-only React Component props that this
* instance will receive.
* @returns {void}
*/ */
componentWillReceiveProps(nextProps: Props) { get useDefaultAvatar() {
// uri const { error, loaded, source } = this.state;
const prevURI = this.props && this.props.uri;
const nextURI = nextProps && nextProps.uri;
const assignState = !this.state;
if (prevURI !== nextURI || assignState) { return !source.uri || error || !loaded;
const nextState = {
backgroundColor: this._getBackgroundColor(nextProps),
source: undefined,
useDefaultAvatar: true
};
if (assignState) {
// eslint-disable-next-line react/no-direct-mutation-state
this.state = nextState;
} else {
this.setState(nextState);
}
// XXX @lyubomir: My logic for the character # bellow is as follows:
// - Technically, URI is supposed to start with a scheme and scheme
// cannot contain the character #.
// - Technically, the character # in URI signals the start of the
// fragment/hash.
// - Technically, the fragment/hash does not imply a retrieval
// action.
// - Practically, the fragment/hash does not always mandate a
// retrieval action. For example, an HTML anchor with an href that
// starts with the character # does not cause a Web browser to
// initiate a retrieval action.
// So I'll use the character # at the start of URI to not initiate
// an image retrieval action.
if (nextURI && !nextURI.startsWith('#')) {
const nextSource = { uri: nextURI };
if (assignState) {
// eslint-disable-next-line react/no-direct-mutation-state
this.state = {
...this.state,
source: nextSource
};
} else {
this._unmounted || this.setState((prevState, props) => {
if (props.uri === nextURI
&& (!prevState.source
|| prevState.source.uri !== nextURI)) {
return { source: nextSource };
}
return {};
});
}
}
}
}
/**
* Notifies this {@code Component} that it will be unmounted and destroyed,
* and most importantly, that it should no longer call
* {@link #setState(Object)}. The {@code Avatar} needs it because it
* downloads images via {@link ImageCache} which will asynchronously notify
* about success.
*
* @inheritdoc
* @returns {void}
*/
componentWillUnmount() {
this._unmounted = true;
} }
/** /**
@ -208,14 +171,26 @@ export default class Avatar extends Component<Props, State> {
_onAvatarLoaded: () => void; _onAvatarLoaded: () => void;
/** /**
* Handler called when the remote image was loaded. When this happens we * Handler called when the remote image loading finishes. This doesn't
* show that instead of the default locally generated one. * necessarily mean the load was successful.
* *
* @private * @private
* @returns {void} * @returns {void}
*/ */
_onAvatarLoaded() { _onAvatarLoaded() {
this._unmounted || this.setState({ useDefaultAvatar: false }); this.setState({ loaded: true });
}
_onAvatarLoadError: () => void;
/**
* Handler called when the remote image loading failed.
*
* @private
* @returns {void}
*/
_onAvatarLoadError() {
this.setState({ error: true });
} }
/** /**
@ -229,15 +204,14 @@ export default class Avatar extends Component<Props, State> {
// regular Image, so we need to wrap it in a view to make it round. // regular Image, so we need to wrap it in a view to make it round.
// https://github.com/facebook/react-native/issues/3198 // https://github.com/facebook/react-native/issues/3198
const { backgroundColor, useDefaultAvatar } = this.state; const { backgroundColor } = this.state;
const imageStyle = this._getImageStyle(); const imageStyle = this._getImageStyle();
const viewStyle = { const viewStyle = {
...imageStyle, ...imageStyle,
backgroundColor, backgroundColor,
display: useDefaultAvatar ? 'flex' : 'none',
// FIXME @lyubomir: Without the opacity bellow I feel like the // FIXME @lyubomir: Without the opacity below I feel like the
// avatar colors are too strong. Besides, we use opacity for the // avatar colors are too strong. Besides, we use opacity for the
// ToolbarButtons. That's where I copied the value from and we // ToolbarButtons. That's where I copied the value from and we
// may want to think about "standardizing" the opacity in the // may want to think about "standardizing" the opacity in the
@ -268,18 +242,32 @@ export default class Avatar extends Component<Props, State> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
_renderAvatar() { _renderAvatar() {
const { source, useDefaultAvatar } = this.state; const { source } = this.state;
const style = { let extraStyle;
...this._getImageStyle(),
display: useDefaultAvatar ? 'none' : 'flex'
};
return ( if (this.useDefaultAvatar) {
// On Android, the image loading indicators don't work unless the
// Glide image is actually created, so we cannot use display: none.
// Instead, render it off-screen, which does the trick.
if (Platform.OS === 'android') {
const windowDimensions = Dimensions.get('window');
extraStyle = {
bottom: -windowDimensions.height,
right: -windowDimensions.width
};
} else {
extraStyle = { display: 'none' };
}
}
return (// $FlowFixMe
<FastImage <FastImage
onLoad = { this._onAvatarLoaded } onError = { this._onAvatarLoadError }
onLoadEnd = { this._onAvatarLoaded }
resizeMode = 'contain' resizeMode = 'contain'
source = { source } source = { source }
style = { style } /> style = { [ this._getImageStyle(), extraStyle ] } />
); );
} }
@ -289,13 +277,36 @@ export default class Avatar extends Component<Props, State> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { source, useDefaultAvatar } = this.state; const { source } = this.state;
return ( return (
<Fragment> <Fragment>
{ source && this._renderAvatar() } { source.uri && this._renderAvatar() }
{ useDefaultAvatar && this._renderDefaultAvatar() } { this.useDefaultAvatar && this._renderDefaultAvatar() }
</Fragment> </Fragment>
); );
} }
} }
/* eslint-disable react/no-multi-comp */
/**
* Implements an avatar as a React Native/mobile {@link Component}.
*
* Note: we use `key` in order to trigger a new component creation in case
* the URI changes.
*/
export default class Avatar extends PureComponent<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
return (
<AvatarContent
key = { this.props.uri }
{ ...this.props } />
);
}
}