Merge pull request #4931 from matrix-org/travis/room-list/sticky-headers

Improve performance and stability in sticky headers for new room list
This commit is contained in:
Travis Ralston 2020-07-09 08:00:56 -06:00 committed by GitHub
commit bd8e1f7198
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 107 additions and 60 deletions

View File

@ -122,16 +122,19 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
} }
.mx_LeftPanel2_roomListWrapper { .mx_LeftPanel2_roomListWrapper {
// Create a flexbox to ensure the containing items cause appropriate overflow.
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
margin-top: 12px; // so we're not up against the search/filter
&.stickyBottom { &.mx_LeftPanel2_roomListWrapper_stickyBottom {
padding-bottom: 32px; padding-bottom: 32px;
} }
&.stickyTop { &.mx_LeftPanel2_roomListWrapper_stickyTop {
padding-top: 32px; padding-top: 32px;
} }
} }

View File

@ -24,10 +24,6 @@ limitations under the License.
margin-left: 8px; margin-left: 8px;
width: 100%; width: 100%;
&:first-child {
margin-top: 12px; // so we're not up against the search/filter
}
.mx_RoomSublist2_headerContainer { .mx_RoomSublist2_headerContainer {
// Create a flexbox to make alignment easy // Create a flexbox to make alignment easy
display: flex; display: flex;
@ -49,10 +45,15 @@ limitations under the License.
padding-bottom: 8px; padding-bottom: 8px;
height: 24px; height: 24px;
// Hide the header container if the contained element is stickied.
// We don't use display:none as that causes the header to go away too.
&.mx_RoomSublist2_headerContainer_hasSticky {
height: 0;
}
.mx_RoomSublist2_stickable { .mx_RoomSublist2_stickable {
flex: 1; flex: 1;
max-width: 100%; max-width: 100%;
z-index: 2; // Prioritize headers in the visible list over sticky ones
// Create a flexbox to make ordering easy // Create a flexbox to make ordering easy
display: flex; display: flex;
@ -64,7 +65,6 @@ limitations under the License.
// when sticky scrolls instead of collapses the list. // when sticky scrolls instead of collapses the list.
&.mx_RoomSublist2_headerContainer_sticky { &.mx_RoomSublist2_headerContainer_sticky {
position: fixed; position: fixed;
z-index: 1; // over top of other elements, but still under the ones in the visible list
height: 32px; // to match the header container height: 32px; // to match the header container
// width set by JS // width set by JS
} }

View File

@ -115,86 +115,130 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}; };
private handleStickyHeaders(list: HTMLDivElement) { private handleStickyHeaders(list: HTMLDivElement) {
// TODO: Evaluate if this has any performance benefit or detriment.
// See https://github.com/vector-im/riot-web/issues/14035
if (this.isDoingStickyHeaders) return; if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true; this.isDoingStickyHeaders = true;
if (window.requestAnimationFrame) { window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
});
} else {
this.doStickyHeaders(list); this.doStickyHeaders(list);
this.isDoingStickyHeaders = false; this.isDoingStickyHeaders = false;
} });
} }
private doStickyHeaders(list: HTMLDivElement) { private doStickyHeaders(list: HTMLDivElement) {
const rlRect = list.getBoundingClientRect(); const topEdge = list.scrollTop;
const bottom = rlRect.bottom; const bottomEdge = list.offsetHeight + list.scrollTop;
const top = rlRect.top;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2"); const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = rlRect.width - headerRightMargin; const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<HTMLDivElement, {
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}>();
let gotBottom = false;
let lastTopHeader; let lastTopHeader;
let firstBottomHeader;
for (const sublist of sublists) { for (const sublist of sublists) {
const slRect = sublist.getBoundingClientRect();
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable"); const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
header.style.removeProperty("display"); // always clear display:none first header.style.removeProperty("display"); // always clear display:none first
if (slRect.top + HEADER_HEIGHT > bottom && !gotBottom) { // When an element is <=40% off screen, make it take over
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); const offScreenFactor = 0.4;
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
header.style.width = `${headerStickyWidth}px`; const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
header.style.removeProperty("top");
gotBottom = true; if (isOffTop || sublist === sublists[0]) {
} else if (((slRect.top - (HEADER_HEIGHT * 0.6) + HEADER_HEIGHT) < top) || sublist === sublists[0]) { targetStyles.set(header, { stickyTop: true });
// the header should become sticky once it is 60% or less out of view at the top.
// We also add HEADER_HEIGHT because the sticky header is put above the scrollable area,
// into the padding of .mx_LeftPanel2_roomListWrapper,
// by subtracting HEADER_HEIGHT from the top below.
// We also always try to make the first sublist header sticky.
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
header.style.width = `${headerStickyWidth}px`;
header.style.top = `${rlRect.top - HEADER_HEIGHT}px`;
if (lastTopHeader) { if (lastTopHeader) {
lastTopHeader.style.display = "none"; lastTopHeader.style.display = "none";
targetStyles.set(lastTopHeader, { makeInvisible: true });
} }
lastTopHeader = header; lastTopHeader = header;
} else if (isOffBottom && !firstBottomHeader) {
targetStyles.set(header, { stickyBottom: true });
firstBottomHeader = header;
} else { } else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); targetStyles.set(header, {}); // nothing == clear
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); }
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); }
header.style.removeProperty("width");
header.style.removeProperty("top"); // Run over the style changes and make them reality. We check to see if we're about to
// cause a no-op update, as adding/removing properties that are/aren't there cause
// layout updates.
for (const header of targetStyles.keys()) {
const style = targetStyles.get(header);
const headerContainer = header.parentElement; // .mx_RoomSublist2_headerContainer
if (style.makeInvisible) {
// we will have already removed the 'display: none', so add it back.
header.style.display = "none";
continue; // nothing else to do, even if sticky somehow
}
if (style.stickyTop) {
if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
}
const newTop = `${list.parentElement.offsetTop}px`;
if (header.style.top !== newTop) {
header.style.top = newTop;
}
} else if (style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
}
}
if (style.stickyTop || style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
}
if (!headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
headerContainer.classList.add("mx_RoomSublist2_headerContainer_hasSticky");
}
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
} else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
}
if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
}
if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
}
if (headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
headerContainer.classList.remove("mx_RoomSublist2_headerContainer_hasSticky");
}
if (header.style.width) {
header.style.removeProperty('width');
}
if (header.style.top) {
header.style.removeProperty('top');
}
} }
} }
// add appropriate sticky classes to wrapper so it has // add appropriate sticky classes to wrapper so it has
// the necessary top/bottom padding to put the sticky header in // the necessary top/bottom padding to put the sticky header in
const listWrapper = list.parentElement; const listWrapper = list.parentElement; // .mx_LeftPanel2_roomListWrapper
if (gotBottom) {
listWrapper.classList.add("stickyBottom");
} else {
listWrapper.classList.remove("stickyBottom");
}
if (lastTopHeader) { if (lastTopHeader) {
listWrapper.classList.add("stickyTop"); listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyTop");
} else { } else {
listWrapper.classList.remove("stickyTop"); listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyTop");
} }
if (firstBottomHeader) {
// ensure scroll doesn't go above the gap left by the header of listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyBottom");
// the first sublist always being sticky if no other header is sticky } else {
if (list.scrollTop < HEADER_HEIGHT) { listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyBottom");
list.scrollTop = HEADER_HEIGHT;
} }
} }