Skip to content

Commit

Permalink
Merge pull request #16973 from kidroca/kidroca/fix/attachment-carouse…
Browse files Browse the repository at this point in the history
…l-virtual-caching

Attachment Carousel Virtual Caching
  • Loading branch information
johnmlee101 authored Apr 18, 2023
2 parents ac4260e + bd0fac0 commit f599146
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 137 deletions.
20 changes: 2 additions & 18 deletions src/components/AttachmentCarousel/CarouselActions/index.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Pressable} from 'react-native';

const propTypes = {
/** Handles onPress events with a callback */
onPress: PropTypes.func.isRequired,

/** Callback to cycle through attachments */
onCycleThroughAttachments: PropTypes.func.isRequired,

/** Styles to be assigned to Carousel */
styles: PropTypes.arrayOf(PropTypes.shape({})).isRequired,

/** Children to render */
children: PropTypes.oneOfType([
PropTypes.func,
PropTypes.node,
]).isRequired,
};

class Carousel extends React.Component {
Expand Down Expand Up @@ -51,11 +38,8 @@ class Carousel extends React.Component {
}

render() {
return (
<Pressable style={this.props.styles} onPress={this.props.onPress}>
{this.props.children}
</Pressable>
);
// This component is only used to listen for keyboard events
return null;
}
}

Expand Down
79 changes: 2 additions & 77 deletions src/components/AttachmentCarousel/CarouselActions/index.native.js
Original file line number Diff line number Diff line change
@@ -1,79 +1,4 @@
import React, {Component} from 'react';
import {PanResponder, Dimensions, Animated} from 'react-native';
import PropTypes from 'prop-types';
import styles from '../../../styles/styles';

const propTypes = {
/** Attachment that's rendered */
children: PropTypes.element.isRequired,

/** Callback to fire when swiping left or right */
onCycleThroughAttachments: PropTypes.func.isRequired,

/** Callback to handle a press event */
onPress: PropTypes.func.isRequired,

/** Boolean to prevent a left swipe action */
canSwipeLeft: PropTypes.bool.isRequired,

/** Boolean to prevent a right swipe action */
canSwipeRight: PropTypes.bool.isRequired,
};

class Carousel extends Component {
constructor(props) {
super(props);
this.pan = new Animated.Value(0);

this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,

onPanResponderMove: (event, gestureState) => Animated.event([null, {
dx: this.pan,
}], {useNativeDriver: false})(event, gestureState),

onPanResponderRelease: (event, gestureState) => {
if (gestureState.dx === 0 && gestureState.dy === 0) {
return this.props.onPress();
}

const deltaSlide = gestureState.dx > 0 ? 1 : -1;
if (Math.abs(gestureState.vx) < 1 || (deltaSlide === 1 && !this.props.canSwipeLeft) || (deltaSlide === -1 && !this.props.canSwipeRight)) {
return Animated.spring(this.pan, {useNativeDriver: false, toValue: 0}).start();
}

const width = Dimensions.get('window').width;
const slideLength = deltaSlide * (width * 1.1);
Animated.timing(this.pan, {useNativeDriver: false, duration: 100, toValue: slideLength}).start(({finished}) => {
if (!finished) {
return;
}

this.props.onCycleThroughAttachments(-deltaSlide);
this.pan.setValue(-slideLength);
Animated.timing(this.pan, {useNativeDriver: false, duration: 100, toValue: 0}).start();
});
},
});
}

render() {
return (
<Animated.View
style={[
styles.w100,
styles.h100,
{transform: [{translateX: this.pan}]},
]}
// eslint-disable-next-line react/jsx-props-no-spreading
{...this.panResponder.panHandlers}
>
{this.props.children}
</Animated.View>
);
}
}

Carousel.propTypes = propTypes;
// No need to implement this in native, because all the native actions (swiping) are handled by the parent component
const Carousel = () => {};

export default Carousel;
182 changes: 140 additions & 42 deletions src/components/AttachmentCarousel/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import {View} from 'react-native';
import {View, FlatList, Pressable} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
Expand Down Expand Up @@ -43,14 +43,25 @@ class AttachmentCarousel extends React.Component {
constructor(props) {
super(props);

this.scrollRef = React.createRef();
this.canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
this.viewabilityConfig = {
// To facilitate paging through the attachments, we want to consider an item "viewable" when it is
// more than 90% visible. When that happens we update the page index in the state.
itemVisiblePercentThreshold: 95,
};

this.cycleThroughAttachments = this.cycleThroughAttachments.bind(this);
this.getItemLayout = this.getItemLayout.bind(this);
this.renderItem = this.renderItem.bind(this);
this.renderCell = this.renderCell.bind(this);
this.updatePage = this.updatePage.bind(this);

this.state = {
attachments: [],
source: this.props.source,
shouldShowArrow: this.canUseTouchScreen,
isForwardDisabled: true,
isBackDisabled: true,
containerWidth: 0,
};
}

Expand All @@ -64,6 +75,7 @@ class AttachmentCarousel extends React.Component {
if (previousReportActionsCount === currentReportActionsCount) {
return;
}

this.makeStateWithReports();
}

Expand All @@ -75,14 +87,27 @@ class AttachmentCarousel extends React.Component {
getAttachment(attachmentItem) {
const source = _.get(attachmentItem, 'source', '');
const file = _.get(attachmentItem, 'file', {name: ''});
this.props.onNavigate({source: addEncryptedAuthTokenToURL(source), file});

return {
source,
file,
};
}

/**
* Calculate items layout information to optimize scrolling performance
* @param {*} data
* @param {Number} index
* @returns {{offset: Number, length: Number, index: Number}}
*/
getItemLayout(data, index) {
return ({
length: this.state.containerWidth,
offset: this.state.containerWidth * index,
index,
});
}

/**
* Toggles the visibility of the arrows
* @param {Boolean} shouldShowArrow
Expand All @@ -92,10 +117,10 @@ class AttachmentCarousel extends React.Component {
}

/**
* This is called when there are new reports to set the state
* Map report actions to attachment items
*/
makeStateWithReports() {
let page;
let page = 0;
const actions = ReportActionsUtils.getSortedReportActions(_.values(this.props.reportActions), true);

/**
Expand Down Expand Up @@ -124,50 +149,96 @@ class AttachmentCarousel extends React.Component {
}
});

const {file} = this.getAttachment(attachments[page]);
this.setState({
page,
attachments,
file,
isForwardDisabled: page === 0,
isBackDisabled: page === attachments.length - 1,
});
}

/**
* Increments or decrements the index to get another selected item
* @param {Number} deltaSlide
*/
*/
cycleThroughAttachments(deltaSlide) {
if ((deltaSlide > 0 && this.state.isForwardDisabled) || (deltaSlide < 0 && this.state.isBackDisabled)) {
const nextIndex = this.state.page - deltaSlide;
const nextItem = this.state.attachments[nextIndex];

if (!nextItem) {
return;
}

this.setState(({attachments, page}) => {
const nextIndex = page - deltaSlide;
const {source, file} = this.getAttachment(attachments[nextIndex]);
return {
page: nextIndex,
source,
file,
isBackDisabled: nextIndex === attachments.length - 1,
isForwardDisabled: nextIndex === 0,
};
});
// The sliding transition is a bit too much on web, because of the wider and bigger images,
// so we only enable it for mobile
this.scrollRef.current.scrollToIndex({index: nextIndex, animated: this.canUseTouchScreen});
}

/**
* Updates the page state when the user navigates between attachments
* @param {Array<{item: *, index: Number}>} viewableItems
*/
updatePage({viewableItems}) {
// Since we can have only one item in view at a time, we can use the first item in the array
// to get the index of the current page
const entry = _.first(viewableItems);
if (!entry) {
return;
}

const page = entry.index;
const {source, file} = this.getAttachment(entry.item);
this.props.onNavigate({source: addEncryptedAuthTokenToURL(source), file});
this.setState({page, source});
}

/**
* Defines how a container for a single attachment should be rendered
* @param {Object} props
* @returns {JSX.Element}
*/
renderCell(props) {
const style = [props.style, styles.h100, {width: this.state.containerWidth}];

// Touch screen devices can toggle between showing and hiding the arrows by tapping on the image/container
// Other devices toggle the arrows through hovering (mouse) instead (see render() root element)
if (!this.canUseTouchScreen) {
// eslint-disable-next-line react/jsx-props-no-spreading
return <View {...props} style={style} />;
}

return (
<Pressable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
onPress={() => this.setState(current => ({shouldShowArrow: !current.shouldShowArrow}))}
style={style}
/>
);
}

/**
* Defines how a single attachment should be rendered
* @param {{ source: String, file: { name: String } }} item
* @returns {JSX.Element}
*/
renderItem({item}) {
const authSource = addEncryptedAuthTokenToURL(item.source);
return <AttachmentView source={authSource} file={item.file} />;
}

render() {
const isPageSet = Number.isInteger(this.state.page);
const authSource = addEncryptedAuthTokenToURL(this.state.source);
const isForwardDisabled = this.state.page === 0;
const isBackDisabled = this.state.page === _.size(this.state.attachments) - 1;

return (
<View
style={[styles.attachmentModalArrowsContainer]}
style={[styles.attachmentModalArrowsContainer, styles.flex1]}
onLayout={({nativeEvent}) => this.setState({containerWidth: nativeEvent.layout.width + 1})}
onMouseEnter={() => this.toggleArrowsVisibility(true)}
onMouseLeave={() => this.toggleArrowsVisibility(false)}
>
{(isPageSet && this.state.shouldShowArrow) && (
{this.state.shouldShowArrow && (
<>
{!this.state.isBackDisabled && (
{!isBackDisabled && (
<View style={styles.leftAttachmentArrow}>
<Tooltip text={this.props.translate('common.previous')}>
<Button
Expand All @@ -181,7 +252,7 @@ class AttachmentCarousel extends React.Component {
</Tooltip>
</View>
)}
{!this.state.isForwardDisabled && (
{!isForwardDisabled && (
<View style={styles.rightAttachmentArrow}>
<Tooltip text={this.props.translate('common.next')}>
<Button
Expand All @@ -197,20 +268,47 @@ class AttachmentCarousel extends React.Component {
)}
</>
)}
<CarouselActions
styles={[styles.attachmentModalArrowsContainer]}
canSwipeLeft={!this.state.isBackDisabled}
canSwipeRight={!this.state.isForwardDisabled}
onPress={() => this.canUseTouchScreen && this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
onCycleThroughAttachments={this.cycleThroughAttachments}
>
<AttachmentView
onPress={() => this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
source={authSource}
key={authSource}
file={this.state.file}

{this.state.containerWidth > 0 && (
<FlatList
listKey="AttachmentCarousel"
horizontal

// Inverting the list for touchscreen devices that can swipe or have an animation when scrolling
// promotes the natural feeling of swiping left/right to go to the next/previous image
// We don't want to invert the list for desktop/web because this interferes with mouse
// wheel or trackpad scrolling (in cases like document preview where you can scroll vertically)
inverted={this.canUseTouchScreen}

decelerationRate="fast"
showsHorizontalScrollIndicator={false}
bounces={false}

// Scroll only one image at a time no matter how fast the user swipes
disableIntervalMomentum
pagingEnabled
snapToAlignment="start"
snapToInterval={this.state.containerWidth}

// Enable scrolling by swiping on mobile (touch) devices only
// disable scroll for desktop/browsers because they add their scrollbars
scrollEnabled={this.canUseTouchScreen}
ref={this.scrollRef}
initialScrollIndex={this.state.page}
initialNumToRender={3}
windowSize={5}
maxToRenderPerBatch={3}
data={this.state.attachments}
CellRendererComponent={this.renderCell}
renderItem={this.renderItem}
getItemLayout={this.getItemLayout}
keyExtractor={item => item.source}
viewabilityConfig={this.viewabilityConfig}
onViewableItemsChanged={this.updatePage}
/>
</CarouselActions>
)}

<CarouselActions onCycleThroughAttachments={this.cycleThroughAttachments} />
</View>
);
}
Expand Down

0 comments on commit f599146

Please sign in to comment.