Skip to content

Commit

Permalink
Implement loading spinner, improve UX for slow internet connection (#187
Browse files Browse the repository at this point in the history
)

* Implement loading spinner, improve UX for slow internet connection
* Fix srcset preloading if it is not set
* Fix blink of spinner on fast internet connection
  • Loading branch information
mkalygin authored and jossmac committed Dec 27, 2017
1 parent a87edfd commit d67f857
Show file tree
Hide file tree
Showing 6 changed files with 967 additions and 39 deletions.
3 changes: 3 additions & 0 deletions README.md
Expand Up @@ -103,6 +103,9 @@ rightArrowTitle | string | ' Next (Right arrow key) ' | Customize right arrow ti
showCloseButton | bool | true | Optionally display a close "X" button in top right corner
showImageCount | bool | true | Optionally display image index, e.g., "3 of 20"
width | number | 1024 | Maximum width of the carousel; defaults to 1024px
spinner | func | BounceLoader | [react-spinners](https://github.com/davidhu2000/react-spinners) spinner component or custom spinner component
spinnerColor | string | 'white' | Color of spinner
spinnerSize | number | 100 | Size of spinner

## Images object

Expand Down
9 changes: 8 additions & 1 deletion examples/src/app.js
@@ -1,5 +1,6 @@
import React from 'react';
import { render } from 'react-dom';
import { RingLoader } from 'react-spinners';
import Gallery from './components/Gallery';
import './example.less';

Expand Down Expand Up @@ -150,7 +151,13 @@ render(
caption,
orientation,
useForDemo,
}))} theme={theme} showThumbnails />
}))}
theme={theme}
spinner={RingLoader}
spinnerColor={'#D40000'}
spinnerSize={50}
showThumbnails
/>
</div>,
document.getElementById('example')
);
3 changes: 3 additions & 0 deletions examples/src/components/Gallery.js
Expand Up @@ -92,6 +92,9 @@ class Gallery extends Component {
onClickThumbnail={this.gotoImage}
onClose={this.closeLightbox}
showThumbnails={this.props.showThumbnails}
spinner={this.props.spinner}
spinnerColor={this.props.spinnerColor}
spinnerSize={this.props.spinnerSize}
theme={this.props.theme}
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -14,6 +14,7 @@
"aphrodite": "^0.5.0",
"prop-types": "^15.6.0",
"react-scrolllock": "^1.0.5",
"react-spinners": "^0.2.5",
"react-transition-group": "^1.1.3"
},
"devDependencies": {
Expand Down
165 changes: 135 additions & 30 deletions src/Lightbox.js
Expand Up @@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { css, StyleSheet } from 'aphrodite';
import ScrollLock from 'react-scrolllock';
import { BounceLoader } from 'react-spinners';

import defaultTheme from './theme';
import Arrow from './components/Arrow';
Expand All @@ -18,13 +19,17 @@ import deepMerge from './utils/deepMerge';
class Lightbox extends Component {
constructor (props) {
super(props);

this.theme = deepMerge(defaultTheme, props.theme);
this.classes = StyleSheet.create(deepMerge(defaultStyles, this.theme));
this.state = { imageLoaded: false };

bindFunctions.call(this, [
'gotoNext',
'gotoPrev',
'closeBackdrop',
'handleKeyboardInput',
'handleImageLoaded',
]);
}
getChildContext () {
Expand Down Expand Up @@ -63,6 +68,12 @@ class Lightbox extends Component {
}
}

// preload current image
if (this.props.currentImage !== nextProps.currentImage || !this.props.isOpen && nextProps.isOpen) {
const img = this.preloadImage(nextProps.currentImage, this.handleImageLoaded);
this.setState({ imageLoaded: img.complete });
}

// add/remove event listeners
if (!this.props.isOpen && nextProps.isOpen && nextProps.enableKeyboardInput) {
window.addEventListener('keydown', this.handleKeyboardInput);
Expand All @@ -81,33 +92,47 @@ class Lightbox extends Component {
// METHODS
// ==============================

preloadImage (idx) {
preloadImage (idx, onload) {
const image = this.props.images[idx];
if (!image) return;

const img = new Image();

// TODO: add error handling for missing images
img.onerror = onload;
img.onload = onload;
img.src = image.src;
img.srcSet = image.srcSet || image.srcset;

if (img.srcSet) img.setAttribute('srcset', img.srcSet);

return img;
}
gotoNext (event) {
if (this.props.currentImage === (this.props.images.length - 1)) return;
const { currentImage, images } = this.props;
const { imageLoaded } = this.state;

if (!imageLoaded || currentImage === (images.length - 1)) return;

if (event) {
event.preventDefault();
event.stopPropagation();
}
this.props.onClickNext();

this.props.onClickNext();
}
gotoPrev (event) {
if (this.props.currentImage === 0) return;
const { currentImage } = this.props;
const { imageLoaded } = this.state;

if (!imageLoaded || currentImage === 0) return;

if (event) {
event.preventDefault();
event.stopPropagation();
}
this.props.onClickPrev();

this.props.onClickPrev();
}
closeBackdrop (event) {
// make sure event only happens if they click the backdrop
Expand All @@ -130,6 +155,9 @@ class Lightbox extends Component {
return false;

}
handleImageLoaded () {
this.setState({ imageLoaded: true });
}

// ==============================
// RENDERERS
Expand Down Expand Up @@ -164,14 +192,13 @@ class Lightbox extends Component {
renderDialog () {
const {
backdropClosesModal,
customControls,
isOpen,
onClose,
showCloseButton,
showThumbnails,
width,
} = this.props;

const { imageLoaded } = this.state;

if (!isOpen) return <span key="closed" />;

let offsetThumbnails = 0;
Expand All @@ -185,32 +212,31 @@ class Lightbox extends Component {
onClick={backdropClosesModal && this.closeBackdrop}
onTouchEnd={backdropClosesModal && this.closeBackdrop}
>
<div className={css(this.classes.content)} style={{ marginBottom: offsetThumbnails, maxWidth: width }}>
<Header
customControls={customControls}
onClose={onClose}
showCloseButton={showCloseButton}
closeButtonTitle={this.props.closeButtonTitle}
/>
{this.renderImages()}
<div>
<div className={css(this.classes.content)} style={{ marginBottom: offsetThumbnails, maxWidth: width }}>
{imageLoaded && this.renderHeader()}
{this.renderImages()}
{this.renderSpinner()}
{imageLoaded && this.renderFooter()}
</div>
{imageLoaded && this.renderThumbnails()}
{imageLoaded && this.renderArrowPrev()}
{imageLoaded && this.renderArrowNext()}
<ScrollLock />
</div>
{this.renderThumbnails()}
{this.renderArrowPrev()}
{this.renderArrowNext()}
<ScrollLock />
</Container>
);
}
renderImages () {
const {
currentImage,
images,
imageCountSeparator,
onClickImage,
showImageCount,
showThumbnails,
} = this.props;

const { imageLoaded } = this.state;

if (!images || !images.length) return null;

const image = images[currentImage];
Expand All @@ -236,7 +262,7 @@ class Lightbox extends Component {
<Swipeable onSwipedLeft={this.gotoNext} onSwipedRight={this.gotoPrev} />
*/}
<img
className={css(this.classes.image)}
className={css(this.classes.image, imageLoaded && this.classes.imageLoaded)}
onClick={onClickImage}
sizes={sizes}
alt={image.alt}
Expand All @@ -247,13 +273,6 @@ class Lightbox extends Component {
maxHeight: `calc(100vh - ${heightOffset})`,
}}
/>
<Footer
caption={images[currentImage].caption}
countCurrent={currentImage + 1}
countSeparator={imageCountSeparator}
countTotal={images.length}
showCount={showImageCount}
/>
</figure>
);
}
Expand All @@ -271,6 +290,62 @@ class Lightbox extends Component {
/>
);
}
renderHeader () {
const {
closeButtonTitle,
customControls,
onClose,
showCloseButton,
} = this.props;

return (
<Header
customControls={customControls}
onClose={onClose}
showCloseButton={showCloseButton}
closeButtonTitle={closeButtonTitle}
/>
);
}
renderFooter () {
const {
currentImage,
images,
imageCountSeparator,
showImageCount,
} = this.props;

if (!images || !images.length) return null;

return (
<Footer
caption={images[currentImage].caption}
countCurrent={currentImage + 1}
countSeparator={imageCountSeparator}
countTotal={images.length}
showCount={showImageCount}
/>
);
}
renderSpinner () {
const {
spinner,
spinnerColor,
spinnerSize,
} = this.props;

const { imageLoaded } = this.state;
const Spinner = spinner;

return (
<div className={css(this.classes.spinner, !imageLoaded && this.classes.spinnerActive)}>
<Spinner
color={spinnerColor}
size={spinnerSize}
/>
</div>
);
}
render () {
return (
<Portal>
Expand All @@ -280,6 +355,10 @@ class Lightbox extends Component {
}
}

const DefaultSpinner = (props) => (
<BounceLoader {...props} />
);

Lightbox.propTypes = {
backdropClosesModal: PropTypes.bool,
closeButtonTitle: PropTypes.string,
Expand All @@ -306,6 +385,9 @@ Lightbox.propTypes = {
showCloseButton: PropTypes.bool,
showImageCount: PropTypes.bool,
showThumbnails: PropTypes.bool,
spinner: PropTypes.func,
spinnerColor: PropTypes.string,
spinnerSize: PropTypes.number,
theme: PropTypes.object,
thumbnailOffset: PropTypes.number,
width: PropTypes.number,
Expand All @@ -321,6 +403,9 @@ Lightbox.defaultProps = {
rightArrowTitle: 'Next (Right arrow key)',
showCloseButton: true,
showImageCount: true,
spinner: DefaultSpinner,
spinnerColor: 'white',
spinnerSize: 100,
theme: {},
thumbnailOffset: 2,
width: 1024,
Expand All @@ -345,6 +430,26 @@ const defaultStyles = {
// disable user select
WebkitTouchCallout: 'none',
userSelect: 'none',

// opacity animation on image load
opacity: 0,
transition: 'opacity 0.3s',
},
imageLoaded: {
opacity: 1,
},
spinner: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',

// opacity animation to make spinner appear with delay
opacity: 0,
transition: 'opacity 0.3s',
},
spinnerActive: {
opacity: 1,
},
};

Expand Down

0 comments on commit d67f857

Please sign in to comment.