Skip to content

Commit 7da621d

Browse files
committed
fix(virtual-scroll): make horizontal scrolling work in RTL
1 parent b20662f commit 7da621d

File tree

8 files changed

+214
-70
lines changed

8 files changed

+214
-70
lines changed

src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
125125
attach(viewport: CdkVirtualScrollViewport) {
126126
this._averager.reset();
127127
this._viewport = viewport;
128-
this._setScrollOffset();
128+
this._renderContentForCurrentOffset();
129129
}
130130

131131
/** Detaches this scroll strategy from the currently attached viewport. */
@@ -143,7 +143,7 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
143143
/** @docs-private Implemented as part of VirtualScrollStrategy. */
144144
onDataLengthChanged() {
145145
if (this._viewport) {
146-
this._setScrollOffset();
146+
this._renderContentForCurrentOffset();
147147
this._checkRenderedContentSize();
148148
}
149149
}
@@ -241,7 +241,7 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
241241
// jitteriness if we just jump to the estimated position. Instead we make sure to scroll by
242242
// the same number of pixels as the scroll magnitude.
243243
if (scrollMagnitude >= viewport.getViewportSize()) {
244-
this._setScrollOffset();
244+
this._renderContentForCurrentOffset();
245245
} else {
246246
// The number of new items to render on the side the user is scrolling towards. Rather than
247247
// just filling the underscan space, we actually fill enough to have a buffer size of
@@ -346,18 +346,12 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
346346
}
347347

348348
/**
349-
* Sets the scroll offset and renders the content we estimate should be shown at that point.
350-
* @param scrollOffset The offset to jump to. If not specified the scroll offset will not be
351-
* changed, but the rendered content will be recalculated based on our estimate of what should
352-
* be shown at the current scroll offset.
349+
* Recalculates the rendered content based on our estimate of what should be shown at the current
350+
* scroll offset.
353351
*/
354-
private _setScrollOffset(scrollOffset?: number) {
352+
private _renderContentForCurrentOffset() {
355353
const viewport = this._viewport!;
356-
if (scrollOffset == null) {
357-
scrollOffset = viewport.measureScrollOffset();
358-
} else {
359-
viewport.setScrollOffset(scrollOffset);
360-
}
354+
const scrollOffset = viewport.measureScrollOffset();
361355
this._lastScrollOffset = scrollOffset;
362356
this._removalFailures = 0;
363357

src/cdk/scrolling/scrolling-module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {CdkVirtualForOf} from './virtual-for-of';
1515
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
1616

1717
@NgModule({
18-
imports: [PlatformModule, BidiModule],
18+
imports: [BidiModule, PlatformModule],
1919
exports: [
2020
BidiModule,
2121
CdkFixedSizeVirtualScroll,

src/cdk/scrolling/virtual-scroll-viewport.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ cdk-virtual-scroll-viewport {
3535
contain: strict;
3636
transform: translateZ(0);
3737
will-change: scroll-position;
38+
-webkit-overflow-scrolling: touch;
3839
}
3940

4041
// Wrapper element for the rendered content. This element will be transformed to push the rendered
@@ -45,6 +46,11 @@ cdk-virtual-scroll-viewport {
4546
left: 0;
4647
contain: content;
4748
will-change: transform;
49+
50+
[dir='rtl'] & {
51+
right: 0;
52+
left: auto;
53+
}
4854
}
4955

5056
.cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper {

src/cdk/scrolling/virtual-scroll-viewport.spec.ts

Lines changed: 141 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -142,20 +142,6 @@ describe('CdkVirtualScrollViewport', () => {
142142
expect(viewport.getOffsetToRenderedContentStart()).toBe(10);
143143
}));
144144

145-
it('should set scroll offset', fakeAsync(() => {
146-
finishInit(fixture);
147-
viewport.setScrollOffset(testComponent.itemSize * 2);
148-
fixture.detectChanges();
149-
flush();
150-
151-
triggerScroll(viewport);
152-
fixture.detectChanges();
153-
flush();
154-
155-
expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2);
156-
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
157-
}));
158-
159145
it('should scroll to offset', fakeAsync(() => {
160146
finishInit(fixture);
161147
viewport.scrollToOffset(testComponent.itemSize * 2);
@@ -585,6 +571,92 @@ describe('CdkVirtualScrollViewport', () => {
585571
expect(() => finishInit(fixture)).toThrow();
586572
}));
587573
});
574+
575+
describe('with RTL direction', () => {
576+
let fixture: ComponentFixture<FixedSizeVirtualScrollWithRtlDirection>;
577+
let testComponent: FixedSizeVirtualScrollWithRtlDirection;
578+
let viewport: CdkVirtualScrollViewport;
579+
let viewportEl: HTMLElement;
580+
let contentWrapperEl: HTMLElement;
581+
582+
beforeEach(() => {
583+
TestBed.configureTestingModule({
584+
imports: [ScrollingModule],
585+
declarations: [FixedSizeVirtualScrollWithRtlDirection],
586+
}).compileComponents();
587+
588+
fixture = TestBed.createComponent(FixedSizeVirtualScrollWithRtlDirection);
589+
testComponent = fixture.componentInstance;
590+
viewport = testComponent.viewport;
591+
viewportEl = viewport.elementRef.nativeElement;
592+
contentWrapperEl =
593+
viewportEl.querySelector('.cdk-virtual-scroll-content-wrapper') as HTMLElement;
594+
});
595+
596+
it('should initially be scrolled all the way right and showing the first item in horizontal' +
597+
' mode', fakeAsync(() => {
598+
testComponent.orientation = 'horizontal';
599+
finishInit(fixture);
600+
601+
expect(viewportEl.scrollLeft).toBe(
602+
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize);
603+
expect(contentWrapperEl.style.transform).toMatch(/translateX\(0(px)?\)/);
604+
expect((contentWrapperEl.children[0] as HTMLElement).innerText).toBe('0 - 0');
605+
}));
606+
607+
it('should scroll through items as user scrolls to the left in horizontal mode',
608+
fakeAsync(() => {
609+
testComponent.orientation = 'horizontal';
610+
finishInit(fixture);
611+
612+
triggerScroll(viewport, 0);
613+
fixture.detectChanges();
614+
flush();
615+
616+
expect(contentWrapperEl.style.transform).toBe('translateX(-300px)');
617+
expect((contentWrapperEl.children[0] as HTMLElement).innerText).toBe('6 - 6');
618+
}));
619+
620+
it('should interpret scrollToOffset amount as an offset from the right in horizontal mode',
621+
fakeAsync(() => {
622+
testComponent.orientation = 'horizontal';
623+
finishInit(fixture);
624+
625+
viewport.scrollToOffset(100);
626+
triggerScroll(viewport);
627+
fixture.detectChanges();
628+
flush();
629+
630+
expect(viewportEl.scrollLeft).toBe(testComponent.itemSize * testComponent.items.length -
631+
testComponent.viewportSize - 100);
632+
}));
633+
634+
it('should scroll to the correct index in horizontal mode', fakeAsync(() => {
635+
testComponent.orientation = 'horizontal';
636+
finishInit(fixture);
637+
638+
viewport.scrollToIndex(2);
639+
triggerScroll(viewport);
640+
fixture.detectChanges();
641+
flush();
642+
643+
expect((contentWrapperEl.children[0] as HTMLElement).innerText).toBe('2 - 2');
644+
}));
645+
646+
it('should emit the scrolled to index in horizontal mode', fakeAsync(() => {
647+
testComponent.orientation = 'horizontal';
648+
finishInit(fixture);
649+
650+
expect(testComponent.scrolledToIndex).toBe(0);
651+
652+
viewport.scrollToIndex(2);
653+
triggerScroll(viewport);
654+
fixture.detectChanges();
655+
flush();
656+
657+
expect(testComponent.scrolledToIndex).toBe(2);
658+
}));
659+
});
588660
});
589661

590662

@@ -597,6 +669,11 @@ function finishInit(fixture: ComponentFixture<any>) {
597669
// On the second cycle we render the items.
598670
fixture.detectChanges();
599671
flush();
672+
673+
// Flush the initial fake scroll event.
674+
animationFrameScheduler.flush();
675+
flush();
676+
fixture.detectChanges();
600677
}
601678

602679
/** Trigger a scroll event on the viewport (optionally setting a new scroll offset). */
@@ -663,3 +740,53 @@ class FixedSizeVirtualScroll {
663740
return this.orientation == 'horizontal' ? this.viewportCrossSize : this.viewportSize;
664741
}
665742
}
743+
744+
@Component({
745+
template: `
746+
<cdk-virtual-scroll-viewport dir="rtl"
747+
[itemSize]="itemSize" [bufferSize]="bufferSize" [orientation]="orientation"
748+
[style.height.px]="viewportHeight" [style.width.px]="viewportWidth"
749+
(scrolledIndexChange)="scrolledToIndex = $event">
750+
<div class="item"
751+
*cdkVirtualFor="let item of items; let i = index; trackBy: trackBy; \
752+
templateCacheSize: templateCacheSize"
753+
[style.height.px]="itemSize" [style.width.px]="itemSize">
754+
{{i}} - {{item}}
755+
</div>
756+
</cdk-virtual-scroll-viewport>
757+
`,
758+
styles: [`
759+
.cdk-virtual-scroll-content-wrapper {
760+
display: flex;
761+
flex-direction: column;
762+
}
763+
764+
.cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper {
765+
flex-direction: row;
766+
}
767+
`],
768+
encapsulation: ViewEncapsulation.None,
769+
})
770+
class FixedSizeVirtualScrollWithRtlDirection {
771+
@ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport;
772+
@ViewChild(CdkVirtualForOf, {read: ViewContainerRef}) virtualForViewContainer: ViewContainerRef;
773+
774+
@Input() orientation = 'vertical';
775+
@Input() viewportSize = 200;
776+
@Input() viewportCrossSize = 100;
777+
@Input() itemSize = 50;
778+
@Input() bufferSize = 0;
779+
@Input() items = Array(10).fill(0).map((_, i) => i);
780+
@Input() trackBy;
781+
@Input() templateCacheSize = 20;
782+
783+
scrolledToIndex = 0;
784+
785+
get viewportWidth() {
786+
return this.orientation == 'horizontal' ? this.viewportSize : this.viewportCrossSize;
787+
}
788+
789+
get viewportHeight() {
790+
return this.orientation == 'horizontal' ? this.viewportCrossSize : this.viewportSize;
791+
}
792+
}

src/cdk/scrolling/virtual-scroll-viewport.ts

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {Directionality} from '@angular/cdk/bidi';
910
import {ListRange} from '@angular/cdk/collections';
1011
import {supportsScrollBehavior} from '@angular/cdk/platform';
1112
import {
@@ -18,12 +19,13 @@ import {
1819
NgZone,
1920
OnDestroy,
2021
OnInit,
22+
Optional,
2123
Output,
2224
ViewChild,
2325
ViewEncapsulation,
2426
} from '@angular/core';
2527
import {animationFrameScheduler, fromEvent, Observable, Subject} from 'rxjs';
26-
import {sampleTime, takeUntil} from 'rxjs/operators';
28+
import {sampleTime, startWith, takeUntil} from 'rxjs/operators';
2729
import {CdkVirtualForOf} from './virtual-for-of';
2830
import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy';
2931

@@ -99,9 +101,6 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy {
99101
/** The size of the viewport (in pixels). */
100102
private _viewportSize = 0;
101103

102-
/** The pending scroll offset to be applied during the next change detection cycle. */
103-
private _pendingScrollOffset: number | null;
104-
105104
/** the currently attached CdkVirtualForOf. */
106105
private _forOf: CdkVirtualForOf<any> | null;
107106

@@ -126,7 +125,8 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy {
126125
constructor(public elementRef: ElementRef<HTMLElement>,
127126
private _changeDetectorRef: ChangeDetectorRef,
128127
private _ngZone: NgZone,
129-
@Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy) {}
128+
@Inject(VIRTUAL_SCROLL_STRATEGY) private _scrollStrategy: VirtualScrollStrategy,
129+
@Optional() private _dir: Directionality) {}
130130

131131
ngOnInit() {
132132
// It's still too early to measure the viewport at this point. Deferring with a promise allows
@@ -138,9 +138,13 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy {
138138
this._scrollStrategy.attach(this);
139139

140140
fromEvent(this.elementRef.nativeElement, 'scroll')
141-
// Sample the scroll stream at every animation frame. This way if there are multiple
142-
// scroll events in the same frame we only need to recheck our layout once.
143-
.pipe(sampleTime(0, animationFrameScheduler), takeUntil(this._destroyed))
141+
.pipe(
142+
// Start off with a fake scroll event so we properly detect our initial position.
143+
startWith(null!),
144+
// Sample the scroll stream at every animation frame. This way if there are multiple
145+
// scroll events in the same frame we only need to recheck our layout once.
146+
sampleTime(0, animationFrameScheduler),
147+
takeUntil(this._destroyed))
144148
.subscribe(() => this._scrollStrategy.onContentScrolled());
145149

146150
this._markChangeDetectionNeeded();
@@ -238,8 +242,12 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy {
238242
* (in pixels).
239243
*/
240244
setRenderedContentOffset(offset: number, to: 'to-start' | 'to-end' = 'to-start') {
245+
// For a horizontal viewport in a right-to-left language we need to translate along the x-axis
246+
// in the negative direction.
247+
const axisDirection = this.orientation == 'horizontal' && this._dir && this._dir.value == 'rtl'
248+
? -1 : 1;
241249
const axis = this.orientation === 'horizontal' ? 'X' : 'Y';
242-
let transform = `translate${axis}(${Number(offset)}px)`;
250+
let transform = `translate${axis}(${Number(axisDirection * offset)}px)`;
243251
this._renderedContentOffset = offset;
244252
if (to === 'to-end') {
245253
transform += ` translate${axis}(-100%)`;
@@ -265,13 +273,20 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy {
265273
}
266274

267275
/**
268-
* Scrolls to the offset on the viewport.
276+
* Scrolls to the given offset from the start of the viewport. Please note that this is not always
277+
* the same as setting `scrollTop` or `scrollLeft`. In a horizontal viewport with right-to-left
278+
* direction, this would be the equivalent of setting a fictional `scrollRight` property.
269279
* @param offset The offset to scroll to.
270280
* @param behavior The ScrollBehavior to use when scrolling. Default is behavior is `auto`.
271281
*/
272282
scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') {
273283
const viewportElement = this.elementRef.nativeElement;
274284

285+
// For a horizontal viewport in a right-to-left language we need to calculate what `scrollRight`
286+
// would be.
287+
offset = this.orientation == 'horizontal' && this._dir && this._dir.value == 'rtl' ?
288+
Math.max(0, this._totalContentSize - this._viewportSize - offset) : offset;
289+
275290
if (supportsScrollBehavior()) {
276291
const offsetDirection = this.orientation === 'horizontal' ? 'left' : 'top';
277292
viewportElement.scrollTo({[offsetDirection]: offset, behavior});
@@ -293,18 +308,16 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy {
293308
this._scrollStrategy.scrollToIndex(index, behavior);
294309
}
295310

296-
/** @docs-private Internal method to set the scroll offset on the viewport. */
297-
setScrollOffset(offset: number) {
298-
// Rather than setting the offset immediately, we batch it up to be applied along with other DOM
299-
// writes during the next change detection cycle.
300-
this._pendingScrollOffset = offset;
301-
this._markChangeDetectionNeeded();
302-
}
303-
304-
/** Gets the current scroll offset of the viewport (in pixels). */
311+
/** Gets the current scroll offset from the start of the viewport (in pixels). */
305312
measureScrollOffset(): number {
306-
return this.orientation === 'horizontal' ?
307-
this.elementRef.nativeElement.scrollLeft : this.elementRef.nativeElement.scrollTop;
313+
if (this.orientation == 'horizontal') {
314+
const offset = this.elementRef.nativeElement.scrollLeft;
315+
// For a horizontal viewport in a right-to-left language we need to calculate what
316+
// `scrollRight` would be.
317+
return this._dir && this._dir.value == 'rtl' ?
318+
Math.max(0, this._totalContentSize - this._viewportSize - offset) : offset;
319+
}
320+
return this.elementRef.nativeElement.scrollTop;
308321
}
309322

310323
/** Measure the combined size of all of the rendered items. */
@@ -367,14 +380,6 @@ export class CdkVirtualScrollViewport implements OnInit, OnDestroy {
367380
// string literals, a variable that can only be 'X' or 'Y', and user input that is run through
368381
// the `Number` function first to coerce it to a numeric value.
369382
this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform;
370-
// Apply the pending scroll offset separately, since it can't be set up as an Angular binding.
371-
if (this._pendingScrollOffset != null) {
372-
if (this.orientation === 'horizontal') {
373-
this.elementRef.nativeElement.scrollLeft = this._pendingScrollOffset;
374-
} else {
375-
this.elementRef.nativeElement.scrollTop = this._pendingScrollOffset;
376-
}
377-
}
378383

379384
const runAfterChangeDetection = this._runAfterChangeDetection;
380385
this._runAfterChangeDetection = [];

0 commit comments

Comments
 (0)