Skip to content

Commit

Permalink
fix: compute native scrollbar track size correctly even if not displa…
Browse files Browse the repository at this point in the history
…yed at application startup

When reloading the application, `SciViewportComponent` inside a remote application showed a native scrollbar, but only if the view was inactive at page reload.

The following issues are fixed:
- If the document is not displayed (e.g., if in a hidden iframe), the track of native scrollbars has no dimension. Thus, it is computed once the document is displayed.
- If the scrollbar natively sits on top of the content (e.g., in OS X), the native scrollbar is used instead

fixes #87
  • Loading branch information
danielwiehl authored and ReToCode committed Jan 30, 2019
1 parent 44c40f4 commit e12718c
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,25 @@

import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core';
import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing';
import { SciNativeScrollbarTrackSize } from './native-scrollbar-track-size';
import { SciNativeScrollbarTrackSizeProvider } from './native-scrollbar-track-size-provider.service';

describe('SciNativeScrollbarTrackSize', () => {
describe('SciNativeScrollbarTrackSizeProvider', () => {

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
providers: [
SciNativeScrollbarTrackSize
SciNativeScrollbarTrackSizeProvider
]
});
}));

it('computes correct scrollbar track sizes', fakeAsync(inject([SciNativeScrollbarTrackSize], (testee: SciNativeScrollbarTrackSize) => {
it('computes correct scrollbar track sizes', fakeAsync(inject([SciNativeScrollbarTrackSizeProvider], (testee: SciNativeScrollbarTrackSizeProvider) => {
const fixture = TestBed.createComponent(AppComponent);
advance(fixture);

expect(testee.vScrollbarTrackWidth).toEqual(fixture.componentInstance.vScrollbarTrackWidth, 'vScrollbarTrackWidth');
expect(testee.hScrollbarTrackHeight).toEqual(fixture.componentInstance.hScrollbarTrackHeight, 'hScrollbarTrackHeight');
expect(testee.trackSize.vScrollbarTrackWidth).toEqual(fixture.componentInstance.vScrollbarTrackWidth, 'vScrollbarTrackWidth');
expect(testee.trackSize.hScrollbarTrackHeight).toEqual(fixture.componentInstance.hScrollbarTrackHeight, 'hScrollbarTrackHeight');
tick();
})));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2018 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

import { Inject, Injectable, NgZone, OnDestroy, Renderer2, RendererFactory2 } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { BehaviorSubject, fromEvent, Observable, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, startWith, takeUntil } from 'rxjs/operators';

/**
* Provides the native scrollbar tracksize.
*/
@Injectable({providedIn: 'root'})
export class SciNativeScrollbarTrackSizeProvider implements OnDestroy {

private readonly _trackSize$ = new BehaviorSubject<NativeScrollbarTrackSize | null>(null);
private readonly _renderer: Renderer2;
private readonly _destroy$ = new Subject<void>();

constructor(@Inject(DOCUMENT) private _document: any, rendererFactory: RendererFactory2, private _zone: NgZone) {
this._renderer = rendererFactory.createRenderer(null, null);
this.installNativeScrollbarTrackSizeListener();
}

/**
* Returns an {Observable} which emits the native scrollbar track size, if any, or else `null`.
*
* Upon subscription, it emits the track size immediately, and then continuously emits when the track size changes.
*/
public get trackSize$(): Observable<NativeScrollbarTrackSize | null> {
return this._trackSize$;
}

/**
* Returns the native scrollbar track size, if any, or else `null`.
*/
public get trackSize(): NativeScrollbarTrackSize {
return this._trackSize$.getValue();
}

/**
* Computes the native scrollbar track size.
*
* @returns native track size, or `null` if the native scrollbars sit on top of the content.
*/
private computeTrackSize(): NativeScrollbarTrackSize {
// create temporary viewport and viewport client with native scrollbars to compute scrolltrack width
const viewportDiv = this._renderer.createElement('div');
this.setStyle(this._renderer, viewportDiv, {
position: 'absolute',
overflow: 'scroll',
height: '100px',
width: '100px',
border: 0,
visibility: 'hidden',
});

const viewportClientDiv = this._renderer.createElement('div');
this.setStyle(this._renderer, viewportClientDiv, {
height: '100%',
width: '100%',
border: 0,
});

this._renderer.appendChild(viewportDiv, viewportClientDiv);
this._renderer.appendChild(this._document.body, viewportDiv);

const trackSize: NativeScrollbarTrackSize = {
hScrollbarTrackHeight: viewportDiv.offsetHeight - viewportClientDiv.offsetHeight,
vScrollbarTrackWidth: viewportDiv.offsetWidth - viewportClientDiv.offsetWidth,
};

// destroy temporary viewport
this._renderer.removeChild(this._document.body, viewportDiv);
if (trackSize.hScrollbarTrackHeight === 0 && trackSize.vScrollbarTrackWidth === 0) {
return null;
}

return trackSize;
}

private installNativeScrollbarTrackSizeListener(): void {
// Listen for window resize events to (re-)compute the native scrollbar track size.
// For instance, if the document is not displayed (e.g., in hidden iframes), the track of native scrollbars do not have a dimension.
// However, once displayed, the window sends a resize event.
this._zone.runOutsideAngular(() => {
fromEvent(window, 'resize')
.pipe(
debounceTime(5),
startWith(null), // trigger the initial computation
map((): NativeScrollbarTrackSize => this.computeTrackSize()),
distinctUntilChanged((t1: NativeScrollbarTrackSize, t2: NativeScrollbarTrackSize) => JSON.stringify(t1) === JSON.stringify(t2)),
takeUntil(this._destroy$),
)
.subscribe((trackSize: NativeScrollbarTrackSize) => {
this._zone.run(() => this._trackSize$.next(trackSize));
});
});
}

private setStyle(renderer: Renderer2, element: Element, style: { [key: string]: any }): void {
Object.keys(style).forEach(key => renderer.setStyle(element, key, style[key]));
}

public ngOnDestroy(): void {
this._renderer.destroy();
this._destroy$.next();
}
}

/**
* Represents the native scrollbar track size.
*/
export interface NativeScrollbarTrackSize {
hScrollbarTrackHeight: number;
vScrollbarTrackWidth: number;
}
58 changes: 0 additions & 58 deletions projects/scion/viewport/src/lib/native-scrollbar-track-size.ts

This file was deleted.

59 changes: 44 additions & 15 deletions projects/scion/viewport/src/lib/scrollable.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
* SPDX-License-Identifier: EPL-2.0
*/

import { Directive, ElementRef, Input, OnChanges, Renderer2, SimpleChanges } from '@angular/core';
import { SciNativeScrollbarTrackSize } from './native-scrollbar-track-size';
import { Directive, ElementRef, Input, OnChanges, OnDestroy, Renderer2, SimpleChanges } from '@angular/core';
import { NativeScrollbarTrackSize, SciNativeScrollbarTrackSizeProvider } from './native-scrollbar-track-size-provider.service';
import { map, takeUntil } from 'rxjs/operators';
import { merge, Subject } from 'rxjs';

/**
* Makes the host `<div>` natively scrollable and optionally hides native scrollbars.
Expand All @@ -20,24 +22,40 @@ import { SciNativeScrollbarTrackSize } from './native-scrollbar-track-size';
@Directive({
selector: 'div[sciScrollable]'
})
export class SciScrollableDirective implements OnChanges {
export class SciScrollableDirective implements OnChanges, OnDestroy {

private _destroy$ = new Subject<void>();
private _inputChange$ = new Subject<void>();

/**
* Controls whether to display native scrollbars. By default, scrollbars are not displayed.
* Controls whether to display native scrollbars.
* Has no effect if the native scrollbar sits on top of the content, e.g. in OS X.
*/
@Input('sciScrollableScrollbarsVisible') // tslint:disable-line:no-input-rename
public scrollbarsVisible: boolean = false; // tslint:disable-line:no-inferrable-types
@Input('sciScrollableDisplayNativeScrollbar') // tslint:disable-line:no-input-rename
public isDisplayNativeScrollbar: boolean = false; // tslint:disable-line:no-inferrable-types

constructor(private _host: ElementRef<HTMLDivElement>,
private _renderer: Renderer2,
private _nativeTrackSize: SciNativeScrollbarTrackSize) {
}

public ngOnChanges(changes: SimpleChanges): void {
this.scrollbarsVisible ? this.showNativeScrollbars() : this.hideNativeScrollbars();
nativeScrollbarTrackSizeProvider: SciNativeScrollbarTrackSizeProvider) {
merge(
nativeScrollbarTrackSizeProvider.trackSize$,
this._inputChange$.pipe(map(() => nativeScrollbarTrackSizeProvider.trackSize))
)
.pipe(takeUntil(this._destroy$))
.subscribe((nativeScrollbarTrackSize: NativeScrollbarTrackSize) => {
if (nativeScrollbarTrackSize === null) { // the native scrollbar sits on top of the content
this.useNativeScrollbars();
}
else {
this.isDisplayNativeScrollbar ? this.useNativeScrollbars() : this.shiftNativeScrollbars(nativeScrollbarTrackSize);
}
});
}

private showNativeScrollbars(): void {
/**
* Uses the native scrollbars when content overflows.
*/
private useNativeScrollbars(): void {
this.setStyle(this._host.nativeElement, {
overflow: 'auto',
top: 0,
Expand All @@ -47,17 +65,28 @@ export class SciScrollableDirective implements OnChanges {
});
}

private hideNativeScrollbars(): void {
/**
* Shifts the native scrollbars out of the visible viewport area.
*/
private shiftNativeScrollbars(nativeScrollbarTrackSize: NativeScrollbarTrackSize): void {
this.setStyle(this._host.nativeElement, {
overflow: 'scroll',
top: 0,
right: `${-this._nativeTrackSize.vScrollbarTrackWidth}px`, // shift native scrollbar out of the visible viewport range
bottom: `${-this._nativeTrackSize.hScrollbarTrackHeight}px`, // shift native scrollbar out of the visible viewport range
right: `${-nativeScrollbarTrackSize.vScrollbarTrackWidth}px`,
bottom: `${-nativeScrollbarTrackSize.hScrollbarTrackHeight}px`,
left: 0
});
}

private setStyle(element: Element, style: { [key: string]: any }): void {
Object.keys(style).forEach(key => this._renderer.setStyle(element, key, style[key]));
}

public ngOnChanges(changes: SimpleChanges): void {
this._inputChange$.next();
}

public ngOnDestroy(): void {
this._destroy$.next();
}
}
4 changes: 2 additions & 2 deletions projects/scion/viewport/src/lib/viewport.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
class="viewport"
tabindex="-1"
sciDimension (sciDimensionChange)="onViewportDimensionChange($event)"
sciScrollable [sciScrollableScrollbarsVisible]="scrollbarStyle === 'native'"
sciScrollable [sciScrollableDisplayNativeScrollbar]="scrollbarStyle === 'native'"
cdkScrollable
(scroll)="onScroll()">
<ng-content></ng-content>
</div>

<!-- render emulated scrollbars which sit on-top -->
<ng-container *ngIf="scrollbarStyle === 'on-top'">
<ng-container *ngIf="scrollbarStyle === 'on-top' && (nativeScrollbarTrackSizeProvider.trackSize$ | async) !== null">
<sci-scrollbar [direction]="'vscroll'" [viewport]="viewport"></sci-scrollbar>
<sci-scrollbar [direction]="'hscroll'" [viewport]="viewport"></sci-scrollbar>
</ng-container>
3 changes: 2 additions & 1 deletion projects/scion/viewport/src/lib/viewport.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { Component, DoCheck, ElementRef, EventEmitter, Input, KeyValueDiffer, KeyValueDiffers, Output, ViewChild } from '@angular/core';
import { NULL_DIMENSION, SciDimension } from '@scion/dimension';
import { SciNativeScrollbarTrackSizeProvider } from './native-scrollbar-track-size-provider.service';

/**
* Represents a viewport with its `<ng-content>` used as its scrollable viewport client and
Expand Down Expand Up @@ -62,7 +63,7 @@ export class SciViewportComponent implements DoCheck {
@Output()
public viewportChange = new EventEmitter<void>();

constructor(differs: KeyValueDiffers) {
constructor(differs: KeyValueDiffers, public nativeScrollbarTrackSizeProvider: SciNativeScrollbarTrackSizeProvider) {
this._viewportClientDiffer = differs.find({}).create();
}

Expand Down

0 comments on commit e12718c

Please sign in to comment.