diff --git a/src/components/AttachmentCarousel/CarouselActions/index.js b/src/components/AttachmentCarousel/CarouselActions/index.js
index 26af8917a04a..9144f0c7d0d1 100644
--- a/src/components/AttachmentCarousel/CarouselActions/index.js
+++ b/src/components/AttachmentCarousel/CarouselActions/index.js
@@ -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 {
@@ -51,11 +38,8 @@ class Carousel extends React.Component {
}
render() {
- return (
-
- {this.props.children}
-
- );
+ // This component is only used to listen for keyboard events
+ return null;
}
}
diff --git a/src/components/AttachmentCarousel/CarouselActions/index.native.js b/src/components/AttachmentCarousel/CarouselActions/index.native.js
index ebc7b7768077..69df7784141c 100644
--- a/src/components/AttachmentCarousel/CarouselActions/index.native.js
+++ b/src/components/AttachmentCarousel/CarouselActions/index.native.js
@@ -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 (
-
- {this.props.children}
-
- );
- }
-}
-
-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;
diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js
index c2caebcc07a2..0a2090dc39b9 100644
--- a/src/components/AttachmentCarousel/index.js
+++ b/src/components/AttachmentCarousel/index.js
@@ -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';
@@ -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,
};
}
@@ -64,6 +75,7 @@ class AttachmentCarousel extends React.Component {
if (previousReportActionsCount === currentReportActionsCount) {
return;
}
+
this.makeStateWithReports();
}
@@ -75,7 +87,6 @@ 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,
@@ -83,6 +94,20 @@ class AttachmentCarousel extends React.Component {
};
}
+ /**
+ * 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
@@ -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);
/**
@@ -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 ;
+ }
+
+ return (
+ 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 ;
}
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 (
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 && (
)}
- {!this.state.isForwardDisabled && (
+ {!isForwardDisabled && (
)}
- this.canUseTouchScreen && this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
- onCycleThroughAttachments={this.cycleThroughAttachments}
- >
- this.toggleArrowsVisibility(!this.state.shouldShowArrow)}
- source={authSource}
- key={authSource}
- file={this.state.file}
+
+ {this.state.containerWidth > 0 && (
+ item.source}
+ viewabilityConfig={this.viewabilityConfig}
+ onViewableItemsChanged={this.updatePage}
/>
-
+ )}
+
+
);
}