Improve left pane UI when loading search results

This commit is contained in:
Evan Hahn 2021-04-02 17:32:55 -05:00 committed by Josh Perez
parent f05d45ac9b
commit d81aaf654f
15 changed files with 420 additions and 93 deletions

View File

@ -1,4 +1,4 @@
// Copyright 2016-2020 Signal Messenger, LLC
// Copyright 2016-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Fonts
@ -116,6 +116,42 @@
}
}
// Search results loading
@mixin search-results-loading-pulsating-background {
animation: search-results-loading-pulsating-background-animation 2s infinite;
@media (prefers-reduced-motion) {
animation: none;
}
@include light-theme {
background: $color-gray-05;
}
@include dark-theme {
background: $color-gray-65;
}
}
@keyframes search-results-loading-pulsating-background-animation {
0% {
opacity: 1;
}
50% {
opacity: 0.55;
}
100% {
opacity: 1;
}
}
@mixin search-results-loading-box($width) {
width: $width;
height: 12px;
border-radius: 4px;
@include search-results-loading-pulsating-background;
}
// Icons
@mixin color-svg($svg, $color, $stretch: true) {

View File

@ -6911,7 +6911,11 @@ button.module-image__border-overlay:focus {
// Module: conversation list
.module-conversation-list {
@include smooth-scroll;
&--scroll-behavior {
&-default {
@include smooth-scroll;
}
}
&__item {
&--archive-button {

View File

@ -0,0 +1,16 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// These styles should match the "real" header.
.module-SearchResultsLoadingFakeHeader {
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 16px;
&::before {
content: '';
display: block;
@include search-results-loading-box(25%);
}
}

View File

@ -0,0 +1,35 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// These styles should match the "real" contact/conversation row.
.module-SearchResultsLoadingFakeRow {
display: flex;
align-items: center;
justify-content: center;
padding-left: 16px;
padding-right: 16px;
&__avatar {
width: 52px;
height: 52px;
border-radius: 100%;
@include search-results-loading-pulsating-background;
}
&__content {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
margin-left: 12px;
&__header {
@include search-results-loading-box(50%);
margin-bottom: 8px;
}
&__message {
@include search-results-loading-box(90%);
}
}
}

View File

@ -38,3 +38,5 @@
@import './components/GroupDialog.scss';
@import './components/GroupTitleInput.scss';
@import './components/MessageAudio.scss';
@import './components/SearchResultsLoadingFakeHeader.scss';
@import './components/SearchResultsLoadingFakeRow.scss';

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { omit } from 'lodash';
import { times, omit } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@ -413,7 +413,7 @@ Line 4, well.`,
});
story.add('Conversations: Various Times', () => {
const times: Array<[number, string]> = [
const pairs: Array<[number, string]> = [
[Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'],
[Date.now() - 24 * 60 * 60 * 1000, 'One day ago'],
[Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'],
@ -423,7 +423,7 @@ Line 4, well.`,
return (
<ConversationList
{...createProps(
times.map(([lastUpdated, messageText]) => ({
pairs.map(([lastUpdated, messageText]) => ({
type: RowType.Conversation,
conversation: createConversation({
lastUpdated,
@ -510,6 +510,18 @@ story.add('Start new conversation', () => (
/>
));
story.add('Search results loading skeleton', () => (
<ConversationList
scrollable={false}
{...createProps([
{ type: RowType.SearchResultsLoadingFakeHeader },
...times(99, () => ({
type: RowType.SearchResultsLoadingFakeRow as const,
})),
])}
/>
));
story.add('Kitchen sink', () => (
<ConversationList
{...createProps([
@ -533,7 +545,6 @@ story.add('Kitchen sink', () => (
type: RowType.MessageSearchResult,
messageId: '123',
},
{ type: RowType.Spinner },
{
type: RowType.ArchiveButton,
archivedConversationsCount: 123,

View File

@ -3,10 +3,11 @@
import React, { useRef, useEffect, useCallback, CSSProperties } from 'react';
import { List, ListRowRenderer } from 'react-virtualized';
import classNames from 'classnames';
import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert';
import { LocalizerType } from '../types/Util';
import { LocalizerType, ScrollBehavior } from '../types/Util';
import {
ConversationListItem,
@ -21,8 +22,9 @@ import {
ContactCheckboxDisabledReason,
} from './conversationList/ContactCheckbox';
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { Spinner as SpinnerComponent } from './Spinner';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
export enum RowType {
ArchiveButton,
@ -33,7 +35,8 @@ export enum RowType {
CreateNewGroup,
Header,
MessageSearchResult,
Spinner,
SearchResultsLoadingFakeHeader,
SearchResultsLoadingFakeRow,
StartNewConversation,
}
@ -76,7 +79,13 @@ type HeaderRowType = {
i18nKey: string;
};
type SpinnerRowType = { type: RowType.Spinner };
type SearchResultsLoadingFakeHeaderType = {
type: RowType.SearchResultsLoadingFakeHeader;
};
type SearchResultsLoadingFakeRowType = {
type: RowType.SearchResultsLoadingFakeRow;
};
type StartNewConversationRowType = {
type: RowType.StartNewConversation;
@ -92,7 +101,8 @@ export type Row =
| CreateNewGroupRowType
| MessageRowType
| HeaderRowType
| SpinnerRowType
| SearchResultsLoadingFakeHeaderType
| SearchResultsLoadingFakeRowType
| StartNewConversationRowType;
export type PropsType = {
@ -105,8 +115,10 @@ export type PropsType = {
// this should only happen if there is a bug somewhere. For example, an inaccurate
// `rowCount`.
getRow: (index: number) => undefined | Row;
scrollBehavior?: ScrollBehavior;
scrollToRowIndex?: number;
shouldRecomputeRowHeights: boolean;
scrollable?: boolean;
i18n: LocalizerType;
@ -121,6 +133,9 @@ export type PropsType = {
startNewConversationFromPhoneNumber: (e164: string) => void;
};
const NORMAL_ROW_HEIGHT = 68;
const HEADER_ROW_HEIGHT = 40;
export const ConversationList: React.FC<PropsType> = ({
dimensions,
getRow,
@ -130,7 +145,9 @@ export const ConversationList: React.FC<PropsType> = ({
onSelectConversation,
renderMessageSearchResult,
rowCount,
scrollBehavior = ScrollBehavior.Default,
scrollToRowIndex,
scrollable = true,
shouldRecomputeRowHeights,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
@ -149,9 +166,15 @@ export const ConversationList: React.FC<PropsType> = ({
const row = getRow(index);
if (!row) {
assert(false, `Expected a row at index ${index}`);
return 68;
return NORMAL_ROW_HEIGHT;
}
switch (row.type) {
case RowType.Header:
case RowType.SearchResultsLoadingFakeHeader:
return HEADER_ROW_HEIGHT;
default:
return NORMAL_ROW_HEIGHT;
}
return row.type === RowType.Header ? 40 : 68;
},
[getRow]
);
@ -235,22 +258,20 @@ export const ConversationList: React.FC<PropsType> = ({
{i18n(row.i18nKey)}
</div>
);
case RowType.Spinner:
return (
<div
className="module-conversation-list__item--spinner"
key={key}
style={style}
>
<SpinnerComponent size="24px" svgSize="small" />
</div>
);
case RowType.MessageSearchResult:
return (
<React.Fragment key={key}>
{renderMessageSearchResult(row.messageId, style)}
</React.Fragment>
);
case RowType.SearchResultsLoadingFakeHeader:
return (
<SearchResultsLoadingFakeHeaderComponent key={key} style={style} />
);
case RowType.SearchResultsLoadingFakeRow:
return (
<SearchResultsLoadingFakeRowComponent key={key} style={style} />
);
case RowType.StartNewConversation:
return (
<StartNewConversationComponent
@ -288,13 +309,17 @@ export const ConversationList: React.FC<PropsType> = ({
return (
<List
className="module-conversation-list"
className={classNames(
'module-conversation-list',
`module-conversation-list--scroll-behavior-${scrollBehavior}`
)}
height={height}
ref={listRef}
rowCount={rowCount}
rowHeight={calculateRowHeight}
rowRenderer={renderRow}
scrollToIndex={scrollToRowIndex}
style={{ overflow: scrollable ? 'auto' : 'hidden' }}
tabIndex={-1}
width={width}
/>

View File

@ -36,7 +36,7 @@ import {
} from './leftPane/LeftPaneSetGroupMetadataHelper';
import * as OS from '../OS';
import { LocalizerType } from '../types/Util';
import { LocalizerType, ScrollBehavior } from '../types/Util';
import { usePrevious } from '../util/hooks';
import { missingCaseError } from '../util/missingCaseError';
@ -337,10 +337,21 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId,
selectedConversationId
);
const rowIndexToScrollTo: undefined | number =
previousSelectedConversationId === selectedConversationId
? undefined
: helper.getRowIndexToScrollTo(selectedConversationId);
const isScrollable = helper.isScrollable();
let rowIndexToScrollTo: undefined | number;
let scrollBehavior: ScrollBehavior;
if (isScrollable) {
rowIndexToScrollTo =
previousSelectedConversationId === selectedConversationId
? undefined
: helper.getRowIndexToScrollTo(selectedConversationId);
scrollBehavior = ScrollBehavior.Default;
} else {
rowIndexToScrollTo = 0;
scrollBehavior = ScrollBehavior.Hard;
}
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
// that AutoSizer properly detects the new size of its slot in the flexbox. The
@ -436,7 +447,9 @@ export const LeftPane: React.FC<PropsType> = ({
}}
renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo}
scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers}
startNewConversationFromPhoneNumber={

View File

@ -0,0 +1,12 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
type PropsType = {
style: CSSProperties;
};
export const SearchResultsLoadingFakeHeader: FunctionComponent<PropsType> = ({
style,
}) => <div className="module-SearchResultsLoadingFakeHeader" style={style} />;

View File

@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
type PropsType = {
style: CSSProperties;
};
export const SearchResultsLoadingFakeRow: FunctionComponent<PropsType> = ({
style,
}) => (
<div className="module-SearchResultsLoadingFakeRow" style={style}>
<div className="module-SearchResultsLoadingFakeRow__avatar" />
<div className="module-SearchResultsLoadingFakeRow__content">
<div className="module-SearchResultsLoadingFakeRow__content__header" />
<div className="module-SearchResultsLoadingFakeRow__content__message" />
</div>
</div>
);

View File

@ -83,6 +83,10 @@ export abstract class LeftPaneHelper<T> {
return undefined;
}
isScrollable(): boolean {
return true;
}
abstract getConversationAndMessageAtIndex(
conversationIndex: number
): undefined | { conversationId: string; messageId?: string };

View File

@ -10,6 +10,14 @@ import { PropsData as ConversationListItemPropsType } from '../conversationList/
import { Intl } from '../Intl';
import { Emojify } from '../conversation/Emojify';
import { assert } from '../../util/assert';
// The "correct" thing to do is to measure the size of the left pane and render enough
// search results for the container height. But (1) that's slow (2) the list is
// virtualized (3) 99 rows is over 6000px tall, taller than most monitors (4) it's fine
// if, in some extremely tall window, we have some empty space. So we just hard-code a
// fairly big number.
const SEARCH_RESULTS_FAKE_ROW_COUNT = 99;
type MaybeLoadedSearchResultsType<T> =
| { isLoading: true }
@ -106,9 +114,14 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
}
getRowCount(): number {
if (this.isLoading()) {
// 1 for the header.
return 1 + SEARCH_RESULTS_FAKE_ROW_COUNT;
}
return this.allResults().reduce(
(result: number, searchResults) =>
result + getRowCountForSearchResult(searchResults),
result + getRowCountForLoadedSearchResults(searchResults),
0
);
}
@ -124,11 +137,21 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
getRow(rowIndex: number): undefined | Row {
const { conversationResults, contactResults, messageResults } = this;
const conversationRowCount = getRowCountForSearchResult(
if (this.isLoading()) {
if (rowIndex === 0) {
return { type: RowType.SearchResultsLoadingFakeHeader };
}
if (rowIndex + 1 <= SEARCH_RESULTS_FAKE_ROW_COUNT) {
return { type: RowType.SearchResultsLoadingFakeRow };
}
return undefined;
}
const conversationRowCount = getRowCountForLoadedSearchResults(
conversationResults
);
const contactRowCount = getRowCountForSearchResult(contactResults);
const messageRowCount = getRowCountForSearchResult(messageResults);
const contactRowCount = getRowCountForLoadedSearchResults(contactResults);
const messageRowCount = getRowCountForLoadedSearchResults(messageResults);
if (rowIndex < conversationRowCount) {
if (rowIndex === 0) {
@ -137,9 +160,10 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
i18nKey: 'conversationsHeader',
};
}
if (conversationResults.isLoading) {
return { type: RowType.Spinner };
}
assert(
!conversationResults.isLoading,
"We shouldn't get here with conversation results still loading"
);
const conversation = conversationResults.results[rowIndex - 1];
return conversation
? {
@ -157,9 +181,10 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
i18nKey: 'contactsHeader',
};
}
if (contactResults.isLoading) {
return { type: RowType.Spinner };
}
assert(
!contactResults.isLoading,
"We shouldn't get here with contact results still loading"
);
const conversation = contactResults.results[localIndex - 1];
return conversation
? {
@ -180,9 +205,10 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
i18nKey: 'messagesHeader',
};
}
if (messageResults.isLoading) {
return { type: RowType.Spinner };
}
assert(
!messageResults.isLoading,
"We shouldn't get here with message results still loading"
);
const message = messageResults.results[localIndex - 1];
return message
? {
@ -192,11 +218,23 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
: undefined;
}
isScrollable(): boolean {
return !this.isLoading();
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean {
const oldIsLoading = new LeftPaneSearchHelper(old).isLoading();
const newIsLoading = this.isLoading();
if (oldIsLoading && newIsLoading) {
return false;
}
if (oldIsLoading !== newIsLoading) {
return true;
}
return searchResultKeys.some(
key =>
getRowCountForSearchResult(old[key]) !==
getRowCountForSearchResult(this[key])
getRowCountForLoadedSearchResults(old[key]) !==
getRowCountForLoadedSearchResults(this[key])
);
}
@ -221,20 +259,27 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
private allResults() {
return [this.conversationResults, this.contactResults, this.messageResults];
}
private isLoading(): boolean {
return this.allResults().some(results => results.isLoading);
}
}
function getRowCountForSearchResult(
function getRowCountForLoadedSearchResults(
searchResults: Readonly<MaybeLoadedSearchResultsType<unknown>>
): number {
let hasHeader: boolean;
let resultRows: number;
// It's possible to call this helper with invalid results (e.g., ones that are loading).
// We could change the parameter of this function, but that adds a bunch of redundant
// checks that are, in the author's opinion, less clear.
if (searchResults.isLoading) {
hasHeader = true;
resultRows = 1; // For the spinner.
} else {
const resultCount = searchResults.results.length;
hasHeader = Boolean(resultCount);
resultRows = resultCount;
assert(
false,
'getRowCountForLoadedSearchResults: Expected this to be called with loaded search results. Returning 0'
);
return 0;
}
const resultRows = searchResults.results.length;
const hasHeader = Boolean(resultRows);
return (hasHeader ? 1 : 0) + resultRows;
}

View File

@ -40,6 +40,39 @@ describe('LeftPaneSearchHelper', () => {
});
describe('getRowCount', () => {
it('returns 100 if any results are loading', () => {
assert.strictEqual(
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}).getRowCount(),
100
);
assert.strictEqual(
new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}).getRowCount(),
100
);
assert.strictEqual(
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
}).getRowCount(),
100
);
});
it('returns 0 when there are no search results', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: false, results: [] },
@ -51,17 +84,6 @@ describe('LeftPaneSearchHelper', () => {
assert.strictEqual(helper.getRowCount(), 0);
});
it("returns 2 rows for each section of search results that's loading", () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: true },
searchTerm: 'foo',
});
assert.strictEqual(helper.getRowCount(), 4);
});
it('returns 1 + the number of results, dropping empty sections', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: {
@ -78,34 +100,41 @@ describe('LeftPaneSearchHelper', () => {
});
describe('getRow', () => {
it('returns header + spinner for loading sections', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
});
it('returns a "loading search results" row if any results are loading', () => {
const helpers = [
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
}),
];
assert.deepEqual(helper.getRow(0), {
type: RowType.Header,
i18nKey: 'conversationsHeader',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.Spinner,
});
assert.deepEqual(helper.getRow(2), {
type: RowType.Header,
i18nKey: 'contactsHeader',
});
assert.deepEqual(helper.getRow(3), {
type: RowType.Spinner,
});
assert.deepEqual(helper.getRow(4), {
type: RowType.Header,
i18nKey: 'messagesHeader',
});
assert.deepEqual(helper.getRow(5), {
type: RowType.Spinner,
helpers.forEach(helper => {
assert.deepEqual(helper.getRow(0), {
type: RowType.SearchResultsLoadingFakeHeader,
});
for (let i = 1; i < 99; i += 1) {
assert.deepEqual(helper.getRow(i), {
type: RowType.SearchResultsLoadingFakeRow,
});
}
assert.isUndefined(helper.getRow(100));
});
});
@ -272,6 +301,54 @@ describe('LeftPaneSearchHelper', () => {
assert.isUndefined(helper.getRow(5));
});
describe('isScrollable', () => {
it('returns false if any results are loading', () => {
const helpers = [
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
}),
];
helpers.forEach(helper => {
assert.isFalse(helper.isScrollable());
});
});
it('returns true if all results have loaded', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
searchTerm: 'foo',
});
assert.isTrue(helper.isScrollable());
});
});
describe('shouldRecomputeRowHeights', () => {
it("returns false if the number of results doesn't change", () => {
const helper = new LeftPaneSearchHelper({
@ -303,7 +380,7 @@ describe('LeftPaneSearchHelper', () => {
);
});
it('returns false when a section goes from loading to loaded with 1 result', () => {
it('returns false when a section completes loading, but not all sections are done (because the pane is still loading overall)', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
@ -324,6 +401,27 @@ describe('LeftPaneSearchHelper', () => {
);
});
it('returns true when all sections finish loading', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
});
assert.isTrue(
helper.shouldRecomputeRowHeights({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
})
);
});
it('returns true if the number of results in a section changes', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: {

View File

@ -29,3 +29,9 @@ export enum ThemeType {
'light' = 'light',
'dark' = 'dark',
}
// These are strings so they can be interpolated into class names.
export enum ScrollBehavior {
Default = 'default',
Hard = 'hard',
}

View File

@ -14384,7 +14384,7 @@
"rule": "React-useRef",
"path": "ts/components/ConversationList.js",
"line": " const listRef = react_1.useRef(null);",
"lineNumber": 49,
"lineNumber": 58,
"reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Used for scroll calculations"