Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Attachment Carousel Virtual Caching #16973

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This side effect was moved to the appropriate place - at the updatePage method - it shouldn't be in a getter


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;
Comment on lines -142 to 167
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the delta > 0 || < 0 checks are simplified:
We try to access an item - if the computed index returns an item - we use otherwise not

(This also lets us remove isForwardDisabled and isBackDisabled from local state)

}

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});
Comment on lines -146 to +172
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state update was moved to updatePage

Here we only tell the view to scroll, which in turn would trigger updatePage and update the state

}

/**
* 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}];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

containerWidth is set after View onLayout callback so there's glitch when window size changes - #17760
To overcome this, we used windowWidth in #18615.


// 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}
/>
);
Comment on lines +201 to +215
Copy link
Contributor Author

@kidroca kidroca Apr 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something I picked up while reviewing the onPress of CarouselActions I moved it here as it was the only thing left for CarouselActions/index.native.js

Having the Pressable wrap the FlatList is undesirable because it interferes with swiping - now we're no longer wrapping Attachments (FlatList) with Pressable (CarouselActions)

}

/**
* 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})}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up!! This line caused a regression in #18222. On iOS, the left edge of the PDF is visible on the previous image due to a missing 1px border calculation . We ended up removing the borderWidth.

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)}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up refactor - the onPress prop seem to have been added to the AttchmentView and then all the child view that it renders (Image, Pdf, ...) for the arrow functionality

There are no more usages of AttachmentView with onPress, so we can remove it (as well as the other views like ImageView)

source={authSource}
key={authSource}
file={this.state.file}

{this.state.containerWidth > 0 && (
<FlatList
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming from #19484:
While introducing FlatList for carousel, it caused weird behavior when press Arrow Left/Right keys.
Because FlatList wants to scroll horizontally as default and it conflicts with main navigating feature of arrow keys.
To fix this, we prevented default (e.preventDefault()) so that FlatList auto scroll doesn't work.

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