-
Notifications
You must be signed in to change notification settings - Fork 3k
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
Changes from all commits
2d555f7
61debcc
7ef2e1f
7246424
478c497
325351a
a121c70
9ebc594
d6a5ba1
3b81830
b7907cf
62678f7
bd0fac0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; |
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'; | ||
|
@@ -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,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 | ||
|
@@ -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; | ||
Comment on lines
-142
to
167
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the (This also lets us remove |
||
} | ||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The state update was moved to Here we only tell the view to scroll, which in turn would trigger |
||
} | ||
|
||
/** | ||
* 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}]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is something I picked up while reviewing the Having the |
||
} | ||
|
||
/** | ||
* 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})} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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 | ||
|
@@ -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 | ||
|
@@ -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)} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow up refactor - the There are no more usages of AttachmentView with |
||
source={authSource} | ||
key={authSource} | ||
file={this.state.file} | ||
|
||
{this.state.containerWidth > 0 && ( | ||
<FlatList | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coming from #19484: |
||
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> | ||
); | ||
} | ||
|
There was a problem hiding this comment.
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