Improve scrolling to new messages (#1345)

* Improve scrolling to new messages

* Experimental scrollTo with measurement

* Add search support

* Review feedback

* Added comment for moreNewMessages index`
This commit is contained in:
Chris Duarte 2018-01-24 13:39:16 -08:00 committed by enahum
parent dd180fdef7
commit cbf3739657
5 changed files with 167 additions and 40 deletions

View file

@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {refreshChannelWithRetry} from 'app/actions/views/channel';
import {makePreparePostIdsForPostList} from 'app/selectors/post_list';
import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from 'app/selectors/post_list';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
@ -13,10 +13,16 @@ import PostList from './post_list';
function makeMapStateToProps() {
const preparePostIds = makePreparePostIdsForPostList();
return (state, ownProps) => {
const postIds = preparePostIds(state, ownProps);
const measureCellLayout = postIds.indexOf(START_OF_NEW_MESSAGES) > -1 || Boolean(ownProps.highlightPostId);
const {deviceHeight} = state.device.dimension;
return {
postIds: preparePostIds(state, ownProps),
deviceHeight,
measureCellLayout,
postIds,
theme: getTheme(state)
};
};

View file

@ -14,15 +14,29 @@ import {makeStyleSheetFromTheme} from 'app/utils/theme';
function NewMessagesDivider(props) {
const style = getStyleFromTheme(props.theme);
let text = (
<FormattedText
id='posts_view.newMsg'
defaultMessage='New Messages'
style={style.text}
/>
);
if (props.moreMessages) {
text = (
<FormattedText
id='mobile.posts_view.moreMsg'
defaultMessage='More New Messages Above'
style={style.text}
/>
);
}
return (
<View style={[style.container, props.style]}>
<View style={style.line}/>
<View style={style.textContainer}>
<FormattedText
id='posts_view.newMsg'
defaultMessage='New Messages'
style={style.text}
/>
{text}
</View>
<View style={style.line}/>
</View>
@ -30,6 +44,7 @@ function NewMessagesDivider(props) {
}
NewMessagesDivider.propTypes = {
moreMessages: PropTypes.bool,
style: ViewPropTypes.style,
theme: PropTypes.object
};

View file

@ -19,6 +19,13 @@ import {makeExtraData} from 'app/utils/list_view';
import DateHeader from './date_header';
import LoadMorePosts from './load_more_posts';
import NewMessagesDivider from './new_messages_divider';
import withLayout from './with_layout';
const DateHeaderWithLayout = withLayout(DateHeader);
const NewMessagesDividerWithLayout = withLayout(NewMessagesDivider);
const PostWithLayout = withLayout(Post);
const INITAL_BATCH_TO_RENDER = 15;
export default class PostList extends PureComponent {
static propTypes = {
@ -27,11 +34,13 @@ export default class PostList extends PureComponent {
}).isRequired,
channelId: PropTypes.string,
currentUserId: PropTypes.string,
deviceHeight: PropTypes.number.isRequired,
highlightPostId: PropTypes.string,
indicateNewMessages: PropTypes.bool,
isSearchResult: PropTypes.bool,
lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types
loadMore: PropTypes.func,
measureCellLayout: PropTypes.bool,
navigator: PropTypes.object,
onPostPress: PropTypes.func,
onRefresh: PropTypes.func,
@ -49,9 +58,11 @@ export default class PostList extends PureComponent {
newMessagesIndex = -1;
scrollToMessageTries = 0;
makeExtraData = makeExtraData();
itemMeasurements = {};
state = {
managedConfig: {}
managedConfig: {},
scrollToMessage: false
};
componentWillMount() {
@ -60,20 +71,24 @@ export default class PostList extends PureComponent {
componentDidMount() {
this.setManagedConfig();
this.scrollList();
}
componentWillReceiveProps(nextProps) {
if (nextProps.postIds !== this.props.postIds) {
this.newMessagesIndex = -1;
}
if (this.props.channelId !== nextProps.channelId) {
this.itemMeasurements = {};
this.newMessageScrolledTo = false;
}
}
componentDidUpdate(prevProps) {
const initialPosts = !prevProps.postIds.length && prevProps.postIds !== this.props.postIds;
if ((prevProps.channelId !== this.props.channelId || initialPosts || this.props.isSearchResult) && this.refs.list) {
this.scrollToMessageTries = 0;
this.scrollList();
if ((prevProps.channelId !== this.props.channelId || initialPosts) && this.refs.list) {
this.scrollToBottomOffset();
} else if ((this.props.measureCellLayout || this.props.isSearchResult) && this.state.scrollToMessage) {
this.scrollListToMessageOffset();
}
}
@ -81,30 +96,47 @@ export default class PostList extends PureComponent {
mattermostManaged.removeEventListener(this.listenerId);
}
scrollList = () => {
scrollToBottomOffset = () => {
InteractionManager.runAfterInteractions(() => {
if (this.props.postIds.length && this.newMessagesIndex !== -1) {
if (this.refs.list) {
this.refs.list.scrollToIndex({
index: this.newMessagesIndex,
viewPosition: 1,
viewOffset: -10,
animated: true
});
}
} else if (this.refs.list) {
this.refs.list.scrollToOffset({y: 0, animated: false});
if (this.refs.list) {
this.refs.list.scrollToOffset({offset: 0, animated: false});
}
});
};
}
scrollListFailed = ({index}) => {
if (this.scrollToMessageTries < 3) {
this.scrollToMessageTries++;
setTimeout(() => {
this.newMessagesIndex = index;
this.scrollList();
}, 300);
getMeasurementOffset = (index) => {
const orderedKeys = Object.keys(this.itemMeasurements).sort().slice(0, index);
return orderedKeys.map((i) => this.itemMeasurements[i]).reduce((a, b) => a + b, 0);
}
scrollListToMessageOffset = () => {
const index = this.moreNewMessages ? this.props.postIds.length : this.newMessagesIndex;
if (index !== -1) {
let offset = this.getMeasurementOffset(index);
const windowHeight = this.state.postListHeight;
if (index !== this.props.postIds.length - 1) {
if (offset < windowHeight) {
return; // no need to scroll since item is in view
} else if (offset > windowHeight) {
offset = (offset - (windowHeight / 2)) + this.itemMeasurements[index];
}
}
InteractionManager.runAfterInteractions(() => {
if (this.refs.list) {
if (!this.moreNewMessages) {
this.newMessageScrolledTo = true;
}
this.refs.list.scrollToOffset({offset, animated: true});
this.newMessagesIndex = -1;
this.moreNewMessages = false;
this.setState({
scrollToMessage: false
});
}
});
}
}
@ -140,17 +172,37 @@ export default class PostList extends PureComponent {
}
};
measureItem = (index, height) => {
this.itemMeasurements[index] = height;
if (this.props.postIds.length === Object.values(this.itemMeasurements).length) {
if (this.newMessagesIndex !== -1 && !this.newMessageScrolledTo) {
this.setState({
scrollToMessage: true
});
}
}
}
renderItem = ({item, index}) => {
if (item === START_OF_NEW_MESSAGES) {
this.newMessagesIndex = index;
// postIds includes a date item after the new message indicator so 2
// needs to be added to the index for the length check to be correct.
this.moreNewMessages = this.props.postIds.length === index + 2;
return (
<NewMessagesDivider
<NewMessagesDividerWithLayout
index={index}
onLayoutCalled={this.measureItem}
theme={this.props.theme}
moreMessages={this.moreNewMessages}
shouldCallOnLayout={this.props.measureCellLayout && !this.newMessageScrolledTo}
/>
);
} else if (item.indexOf(DATE_LINE) === 0) {
const date = item.substring(DATE_LINE.length);
return this.renderDateHeader(new Date(date));
return this.renderDateHeader(new Date(date), index);
}
const postId = item;
@ -163,9 +215,14 @@ export default class PostList extends PureComponent {
return this.renderPost(postId, previousPostId, nextPostId, index);
};
renderDateHeader = (date) => {
renderDateHeader = (date, index) => {
return (
<DateHeader date={date}/>
<DateHeaderWithLayout
date={date}
index={index}
onLayoutCalled={this.measureItem}
shouldCallOnLayout={this.props.measureCellLayout && !this.newMessageScrolledTo}
/>
);
};
@ -186,17 +243,20 @@ export default class PostList extends PureComponent {
}
return (
<Post
<PostWithLayout
postId={postId}
previousPostId={previousPostId}
nextPostId={nextPostId}
highlight={highlight}
index={index}
renderReplies={renderReplies}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
onPress={onPostPress}
navigator={navigator}
managedConfig={managedConfig}
onLayoutCalled={this.measureItem}
shouldCallOnLayout={this.props.measureCellLayout && !this.newMessageScrolledTo}
/>
);
};
@ -223,6 +283,13 @@ export default class PostList extends PureComponent {
);
};
onLayout = (event) => {
const {height} = event.nativeEvent.layout;
this.setState({
postListHeight: height
});
}
render() {
const {
channelId,
@ -241,16 +308,17 @@ export default class PostList extends PureComponent {
return (
<FlatList
onLayout={this.onLayout}
ref='list'
data={postIds}
extraData={this.makeExtraData(channelId, highlightPostId)}
initialNumToRender={15}
initialNumToRender={INITAL_BATCH_TO_RENDER}
maxToRenderPerBatch={INITAL_BATCH_TO_RENDER + 1}
inverted={true}
keyExtractor={this.keyExtractor}
ListFooterComponent={this.renderFooter}
onEndReached={loadMore}
onEndReachedThreshold={Platform.OS === 'ios' ? 0 : 1}
onScrollToIndexFailed={this.scrollListFailed}
{...refreshControl}
renderItem={this.renderItem}
contentContainerStyle={styles.postListContent}

View file

@ -0,0 +1,37 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
function withLayout(WrappedComponent) {
return class WithLayoutComponent extends PureComponent {
static propTypes = {
index: PropTypes.number.isRequired,
onLayoutCalled: PropTypes.func.isRequired,
shouldCallOnLayout: PropTypes.bool
};
onLayout = (event) => {
const {height} = event.nativeEvent.layout;
this.props.onLayoutCalled(this.props.index, height);
};
render() {
const {index, onLayoutCalled, shouldCallOnLayout, ...otherProps} = this.props; //eslint-disable-line no-unused-vars
if (shouldCallOnLayout) {
return (
<View onLayout={this.onLayout}>
<WrappedComponent {...otherProps}/>
</View>
);
}
return <WrappedComponent {...otherProps}/>;
}
};
}
export default withLayout;

View file

@ -2098,6 +2098,7 @@
"mobile.post_textbox.empty.message": "You are trying to send an empty message.\nPlease make sure you have a message or at least one attached file.",
"mobile.post_textbox.empty.ok": "OK",
"mobile.post_textbox.empty.title": "Empty Message",
"mobile.posts_view.moreMsg": "More New Messages Above",
"mobile.rename_channel.display_name_maxLength": "Channel name must be less than {maxLength, number} characters",
"mobile.rename_channel.display_name_minLength": "Channel name must be {minLength, number} or more characters",
"mobile.rename_channel.display_name_required": "Channel name is required",