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 && (