|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright Google Inc. All Rights Reserved. |
| 4 | + * |
| 5 | + * Use of this source code is governed by an MIT-style license that can be |
| 6 | + * found in the LICENSE file at https://angular.io/license |
| 7 | + */ |
| 8 | +import {Component, Directive, Input, Output, |
| 9 | + OnDestroy, AfterViewInit, ElementRef, Injectable, Optional} from '@angular/core'; |
| 10 | +import {Scrollable} from '../core/overlay/scroll/scrollable'; |
| 11 | +import {extendObject} from '../core/util/object-extend'; |
| 12 | + |
| 13 | + |
| 14 | +/** |
| 15 | + * Directive that marks an element as a "sticky region", meant to contain exactly one sticky-header |
| 16 | + * along with the content associated with that header. The sticky-header inside of the region will |
| 17 | + * "stick" to the top of the scrolling container as long as this region is within the scrolling |
| 18 | + * viewport. |
| 19 | + * |
| 20 | + * If a user does not explicitly define a sticky-region for a sticky-header, the direct |
| 21 | + * parent node of the sticky-header will be used as the sticky-region. |
| 22 | + */ |
| 23 | +@Directive({ |
| 24 | + selector: '[cdkStickyRegion]', |
| 25 | +}) |
| 26 | +export class CdkStickyRegion { |
| 27 | + constructor(public readonly _elementRef: ElementRef) { } |
| 28 | +} |
| 29 | + |
| 30 | + |
| 31 | +const STICK_START_CLASS = 'mat-stick-start'; |
| 32 | +const STICK_END_CLASS = 'mat-stick-end'; |
| 33 | +@Directive({ |
| 34 | + selector: '[cdkStickyHeader]', |
| 35 | +}) |
| 36 | +/** |
| 37 | + * Directive that marks an element as a sticky-header. Inside of a scrolling container (marked with |
| 38 | + * cdkScrollable), this header will "stick" to the top of the scrolling viewport while its sticky |
| 39 | + * region (see cdkStickyRegion) is in view. |
| 40 | + */ |
| 41 | +export class CdkStickyHeader implements OnDestroy, AfterViewInit { |
| 42 | + |
| 43 | + /** |
| 44 | + * Set the sticky-header's z-index as 10 in default. Make it as an input |
| 45 | + * variable to make user be able to customize the zIndex when |
| 46 | + * the sticky-header's zIndex is not the largest in current page. |
| 47 | + * Because if the sticky-header's zIndex is not the largest in current page, |
| 48 | + * it may be sheltered by other element when being stuck. |
| 49 | + */ |
| 50 | + @Input('cdkStickyHeaderZIndex') zIndex: number = 10; |
| 51 | + @Input('cdkStickyParentRegion') parentRegion: HTMLElement; |
| 52 | + @Input('cdkStickyScrollableRegion') scrollableRegion: HTMLElement; |
| 53 | + |
| 54 | + private _onScrollBind: EventListener = this.onScroll.bind(this); |
| 55 | + private _onResizeBind: EventListener = this.onResize.bind(this); |
| 56 | + private _onTouchMoveBind: EventListener = this.onTouchMove.bind(this); |
| 57 | + isStuck: boolean = false; |
| 58 | + /** |
| 59 | + * The element with the 'cdkStickyHeader' tag |
| 60 | + */ |
| 61 | + element: HTMLElement; |
| 62 | + /** |
| 63 | + * The upper container element with the 'cdkStickyRegion' tag |
| 64 | + */ |
| 65 | + stickyParent: HTMLElement | null; |
| 66 | + /** |
| 67 | + * The upper scrollable container |
| 68 | + */ |
| 69 | + upperScrollableContainer: HTMLElement; |
| 70 | + |
| 71 | + /** |
| 72 | + * The original css of the sticky element, used to reset the sticky element |
| 73 | + * when it is being unstuck |
| 74 | + */ |
| 75 | + originalCss: any; |
| 76 | + |
| 77 | + /** |
| 78 | + * 'getBoundingClientRect().top' of CdkStickyRegion of current sticky header. |
| 79 | + * It is used with '_scrollFinish' to judge whether the current header |
| 80 | + * need to be stuck. |
| 81 | + */ |
| 82 | + private _containerStart: number; |
| 83 | + /** |
| 84 | + * It is 'The bottom of CdkStickyRegion of current sticky header - the height |
| 85 | + * of current header', which is used with '_containerStart' to judge whether the current header |
| 86 | + * need to be stuck. |
| 87 | + */ |
| 88 | + private _scrollFinish: number; |
| 89 | + /** |
| 90 | + * The width of the sticky-header when it is stuck. |
| 91 | + */ |
| 92 | + private _scrollingWidth: number; |
| 93 | + |
| 94 | + constructor(_element: ElementRef, |
| 95 | + scrollable: Scrollable, |
| 96 | + @Optional() public parentReg: CdkStickyRegion) { |
| 97 | + this.element = _element.nativeElement; |
| 98 | + this.upperScrollableContainer = scrollable.getElementRef().nativeElement; |
| 99 | + this.scrollableRegion = scrollable.getElementRef().nativeElement; |
| 100 | + } |
| 101 | + |
| 102 | + ngAfterViewInit(): void { |
| 103 | + this.stickyParent = this.parentReg != null ? |
| 104 | + this.parentReg._elementRef.nativeElement : this.element.parentElement; |
| 105 | + this.originalCss = { |
| 106 | + zIndex: this.getCssValue(this.element, 'zIndex'), |
| 107 | + position: this.getCssValue(this.element, 'position'), |
| 108 | + top: this.getCssValue(this.element, 'top'), |
| 109 | + right: this.getCssValue(this.element, 'right'), |
| 110 | + left: this.getCssValue(this.element, 'left'), |
| 111 | + bottom: this.getCssValue(this.element, 'bottom'), |
| 112 | + width: this.getCssValue(this.element, 'width'), |
| 113 | + }; |
| 114 | + this.attach(); |
| 115 | + this.defineRestrictionsAndStick(); |
| 116 | + } |
| 117 | + |
| 118 | + ngOnDestroy(): void { |
| 119 | + this.upperScrollableContainer.removeEventListener('scroll', this._onScrollBind); |
| 120 | + this.upperScrollableContainer.removeEventListener('resize', this._onResizeBind); |
| 121 | + this.upperScrollableContainer.removeEventListener('touchmove', this._onTouchMoveBind); |
| 122 | + } |
| 123 | + |
| 124 | + attach() { |
| 125 | + this.upperScrollableContainer.addEventListener('scroll', this._onScrollBind, false); |
| 126 | + this.upperScrollableContainer.addEventListener('resize', this._onResizeBind, false); |
| 127 | + |
| 128 | + // Have to add a 'onTouchMove' listener to make sticky header work on mobile phones |
| 129 | + this.upperScrollableContainer.addEventListener('touchmove', this._onTouchMoveBind, false); |
| 130 | + } |
| 131 | + |
| 132 | + onScroll(): void { |
| 133 | + this.defineRestrictionsAndStick(); |
| 134 | + } |
| 135 | + |
| 136 | + onTouchMove(): void { |
| 137 | + this.defineRestrictionsAndStick(); |
| 138 | + } |
| 139 | + |
| 140 | + onResize(): void { |
| 141 | + this.defineRestrictionsAndStick(); |
| 142 | + // If there's already a header being stick when the page is |
| 143 | + // resized. The CSS style of the cdkStickyHeader element may be not fit |
| 144 | + // the resized window. So we need to unstuck it then re-stick it. |
| 145 | + // unstuck() can set 'isStuck' to FALSE. Then stickElement() can work. |
| 146 | + if (this.isStuck) { |
| 147 | + this.unstuckElement(); |
| 148 | + this.stickElement(); |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + /** |
| 153 | + * define the restrictions of the sticky header(including stickyWidth, |
| 154 | + * when to start, when to finish) |
| 155 | + */ |
| 156 | + defineRestrictions(): void { |
| 157 | + if(this.stickyParent == null) { |
| 158 | + return; |
| 159 | + } |
| 160 | + let containerTop: any = this.stickyParent.getBoundingClientRect(); |
| 161 | + let elemHeight: number = this.element.offsetHeight; |
| 162 | + let containerHeight: number = this.getCssNumber(this.stickyParent, 'height'); |
| 163 | + this._containerStart = containerTop.top; |
| 164 | + |
| 165 | + // the padding of the element being sticked |
| 166 | + let elementPadding: any = this.getCssValue(this.element, 'padding'); |
| 167 | + |
| 168 | + let paddingNumber: any = Number(elementPadding.slice(0, -2)); |
| 169 | + this._scrollingWidth = this.upperScrollableContainer.clientWidth - |
| 170 | + paddingNumber - paddingNumber; |
| 171 | + |
| 172 | + this._scrollFinish = this._containerStart + (containerHeight - elemHeight); |
| 173 | + } |
| 174 | + |
| 175 | + /** |
| 176 | + * Reset element to its original CSS |
| 177 | + */ |
| 178 | + resetElement(): void { |
| 179 | + this.element.classList.remove(STICK_START_CLASS); |
| 180 | + extendObject(this.element.style, this.originalCss); |
| 181 | + } |
| 182 | + |
| 183 | + /** |
| 184 | + * Stuck element, make the element stick to the top of the scrollable container. |
| 185 | + */ |
| 186 | + stickElement(): void { |
| 187 | + this.isStuck = true; |
| 188 | + |
| 189 | + this.element.classList.remove(STICK_END_CLASS); |
| 190 | + this.element.classList.add(STICK_START_CLASS); |
| 191 | + |
| 192 | + /** |
| 193 | + * Have to add the translate3d function for the sticky element's css style. |
| 194 | + * Because iPhone and iPad's browser is using its owning rendering engine. And |
| 195 | + * even if you are using Chrome on an iPhone, you are just using Safari with |
| 196 | + * a Chrome skin around it. |
| 197 | + * |
| 198 | + * Safari on iPad and Safari on iPhone do not have resizable windows. |
| 199 | + * In Safari on iPhone and iPad, the window size is set to the size of |
| 200 | + * the screen (minus Safari user interface controls), and cannot be changed |
| 201 | + * by the user. To move around a webpage, the user changes the zoom level and position |
| 202 | + * of the viewport as they double tap or pinch to zoom in or out, or by touching |
| 203 | + * and dragging to pan the page. As a user changes the zoom level and position of the |
| 204 | + * viewport they are doing so within a viewable content area of fixed size |
| 205 | + * (that is, the window). This means that webpage elements that have their position |
| 206 | + * "fixed" to the viewport can end up outside the viewable content area, offscreen. |
| 207 | + * |
| 208 | + * So the 'position: fixed' does not work on iPhone and iPad. To make it work, |
| 209 | + * 'translate3d(0,0,0)' needs to be used to force Safari re-rendering the sticky element. |
| 210 | + **/ |
| 211 | + this.element.style.transform = 'translate3d(0px,0px,0px)'; |
| 212 | + |
| 213 | + let stuckRight: any = this.upperScrollableContainer.getBoundingClientRect().right; |
| 214 | + |
| 215 | + let stickyCss:any = { |
| 216 | + zIndex: this.zIndex, |
| 217 | + position: 'fixed', |
| 218 | + top: this.upperScrollableContainer.offsetTop + 'px', |
| 219 | + right: stuckRight + 'px', |
| 220 | + left: this.upperScrollableContainer.offsetLeft + 'px', |
| 221 | + bottom: 'auto', |
| 222 | + width: this._scrollingWidth + 'px', |
| 223 | + }; |
| 224 | + extendObject(this.element.style, stickyCss); |
| 225 | + } |
| 226 | + |
| 227 | + /** |
| 228 | + * Unstuck element: When an element reaches the bottom of its cdkStickyRegion, |
| 229 | + * It should be unstuck. And its position will be set as 'relative', its bottom |
| 230 | + * will be set as '0'. So it will be stick at the bottom of its cdkStickyRegion and |
| 231 | + * will be scrolled up with its cdkStickyRegion element. In this way, the sticky header |
| 232 | + * can be changed smoothly when two sticky header meet and the later one need to replace |
| 233 | + * the former one. |
| 234 | + */ |
| 235 | + unstuckElement(): void { |
| 236 | + this.isStuck = false; |
| 237 | + |
| 238 | + if(this.stickyParent == null) { |
| 239 | + return; |
| 240 | + } |
| 241 | + |
| 242 | + this.element.classList.add(STICK_END_CLASS); |
| 243 | + this.stickyParent.style.position = 'relative'; |
| 244 | + let unstuckCss: any = { |
| 245 | + position: 'absolute', |
| 246 | + top: 'auto', |
| 247 | + right: '0', |
| 248 | + left: 'auto', |
| 249 | + bottom: '0', |
| 250 | + width: this.originalCss.width, |
| 251 | + }; |
| 252 | + extendObject(this.element.style, unstuckCss); |
| 253 | + } |
| 254 | + |
| 255 | + |
| 256 | + /** |
| 257 | + * 'sticker()' function contains the main logic of sticky-header. It decides when |
| 258 | + * a header should be stick and when should it be unstuck. It will first get |
| 259 | + * the offsetTop of the upper scrollable container. And then get the Start and End |
| 260 | + * of the sticky-header's stickyRegion. |
| 261 | + * The header will be stick if 'stickyRegion Start < container offsetTop < stickyRegion End'. |
| 262 | + * And when 'stickyRegion End < container offsetTop', the header will be unstuck. It will be |
| 263 | + * stick to the bottom of its stickyRegion container and being scrolled up with its stickyRegion |
| 264 | + * container. |
| 265 | + * When 'stickyRegion Start > container offsetTop', which means the header come back to the |
| 266 | + * middle of the scrollable container, the header will be reset to its |
| 267 | + * original CSS. |
| 268 | + * A flag, isStuck. is used in this function. When a header is stick, isStuck = true. |
| 269 | + * And when the 'isStuck' flag is TRUE, the sticky-header will not be repaint, which |
| 270 | + * decreases the times on repainting sticky-header. |
| 271 | + */ |
| 272 | + sticker(): void { |
| 273 | + let currentPosition: number = this.upperScrollableContainer.offsetTop; |
| 274 | + |
| 275 | + // unstuck when the element is scrolled out of the sticky region |
| 276 | + if (this.isStuck && |
| 277 | + (currentPosition < this._containerStart || currentPosition > this._scrollFinish) || |
| 278 | + currentPosition >= this._scrollFinish) { |
| 279 | + this.resetElement(); |
| 280 | + if (currentPosition >= this._scrollFinish) { |
| 281 | + this.unstuckElement(); |
| 282 | + } |
| 283 | + this.isStuck = false; // stick when the element is within the sticky region |
| 284 | + } else if ( this.isStuck === false && |
| 285 | + currentPosition > this._containerStart && currentPosition < this._scrollFinish) { |
| 286 | + this.stickElement(); |
| 287 | + } |
| 288 | + } |
| 289 | + |
| 290 | + defineRestrictionsAndStick(): void { |
| 291 | + this.defineRestrictions(); |
| 292 | + this.sticker(); |
| 293 | + } |
| 294 | + |
| 295 | + generateCssStyle(zIndex:any, position:any, top:any, right:any, |
| 296 | + left:any, bottom:any, width:any): any { |
| 297 | + let curCSS = { |
| 298 | + zIndex: zIndex, |
| 299 | + position: position, |
| 300 | + top: top, |
| 301 | + right: right, |
| 302 | + left: left, |
| 303 | + bottom: bottom, |
| 304 | + width: width, |
| 305 | + }; |
| 306 | + return curCSS; |
| 307 | +} |
| 308 | + |
| 309 | + |
| 310 | + private getCssValue(element: any, property: string): any { |
| 311 | + let result: any = ''; |
| 312 | + if (typeof window.getComputedStyle !== 'undefined') { |
| 313 | + result = window.getComputedStyle(element, '').getPropertyValue(property); |
| 314 | + } else if (typeof element.currentStyle !== 'undefined') { |
| 315 | + result = element.currentStyle.property; |
| 316 | + } |
| 317 | + return result; |
| 318 | + } |
| 319 | + |
| 320 | + private getCssNumber(element: any, property: string): number { |
| 321 | + return parseInt(this.getCssValue(element, property), 10) || 0; |
| 322 | + } |
| 323 | +} |
0 commit comments