Last active
May 4, 2024 01:00
-
-
Save NathanWalker/15aab533750623a1139e33cb46d63b25 to your computer and use it in GitHub Desktop.
nsIf - Specialized NativeScript directive for Angular to optimize view show/hide with change detection under mobile constraints
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Directive, | |
ElementRef, | |
EmbeddedViewRef, | |
Input, | |
OnDestroy, | |
OnInit, | |
Optional, | |
TemplateRef, | |
ViewContainerRef, | |
} from '@angular/core'; | |
import { LayoutBase, View } from '@nativescript/core'; | |
import { Subscription, fromEventPattern } from 'rxjs'; | |
function fromNativeScriptEventTarget(view: View, eventName: string) { | |
return fromEventPattern( | |
(handler: (data: unknown) => void) => { | |
view.on(eventName, handler); | |
}, | |
(handler: (data: unknown) => void) => { | |
view.off(eventName, handler); | |
} | |
); | |
} | |
export class NsIfContext<T = unknown> { | |
$implicit: T; | |
nsIf: T; | |
} | |
/** | |
* This directive differs from *ngIf in that it will not be removed from the DOM, only hidden | |
* to use this you have to ensure that your "template" will not break if this condition is false | |
* if you use it in the following format: *nsIf="condition" or *nsIf="condition; visibilityType: 'hidden'" | |
* then the view will be created instantly but will be detached from change detection if it's not visible. | |
*/ | |
@Directive({ | |
selector: '[nsIf]', | |
standalone: true, | |
}) | |
export class nsIfDirective<T = unknown> implements OnInit, OnDestroy { | |
private _context = new NsIfContext<T>(); | |
private _isVisible: T; | |
/** | |
* The Boolean expression to evaluate as the condition for showing a template. | |
*/ | |
@Input('nsIf') | |
private set isVisible(v: T) { | |
this._context.$implicit = this._context.nsIf = this._isVisible = v; | |
this.setVisibility(); | |
} | |
private get isVisible(): T { | |
return this._isVisible; | |
} | |
private _visibilityType: 'collapse' | 'hidden' = 'collapse'; | |
@Input() | |
private set visibilityType(v: 'collapse' | 'hidden') { | |
this._visibilityType = v; | |
this.setVisibility(); | |
} | |
@Input() | |
private set nsIfVisibilityType(v: 'collapse' | 'hidden') { | |
this._visibilityType = v; | |
this.setVisibility(); | |
} | |
private _unloadWhenHidden = false; | |
@Input() | |
set nsIfUnloadWhenHidden(v: boolean) { | |
this._unloadWhenHidden = v; | |
this.handleLoadedState(); | |
} | |
private viewRef: EmbeddedViewRef<NsIfContext<T>>; | |
private currentVisibility: string | null = null; | |
private initialized = false; | |
private isDetached = true; | |
private loadedSubscription: Subscription; | |
constructor( | |
private viewContainer: ViewContainerRef, | |
@Optional() private elemRef: ElementRef<LayoutBase>, | |
@Optional() private templateRef: TemplateRef<NsIfContext<T>> | |
) { | |
this.ensureViewCreated(); | |
} | |
ngOnInit() { | |
this.initialized = true; | |
this.setVisibility(); | |
if (this.isDetached) { | |
// run first change detection so the children are properly created and initialized | |
this.viewRef?.detectChanges(); | |
this.handleLoadedState(); | |
} | |
} | |
setVisibility() { | |
if (!this.initialized) { | |
return; | |
} | |
const targetVisibility = this.isVisible ? 'visible' : this._visibilityType; | |
if (this.currentVisibility === targetVisibility) { | |
// Early return improves performance by skipping setting the visibility | |
// (which is kinda slow) and prevents the call to detectChanges() | |
return; | |
} else { | |
this.currentVisibility = targetVisibility; | |
} | |
if (this.templateRef) { | |
if (this.isVisible) { | |
this.ensureViewAttached(); | |
this.viewRef.detectChanges(); | |
} else { | |
this.detachView(); | |
} | |
this.viewRef.rootNodes.forEach(node => (node.visibility = targetVisibility)); | |
} else { | |
this.elemRef.nativeElement.visibility = targetVisibility; | |
} | |
this.handleLoadedState(); | |
} | |
private detachView() { | |
if (!this.viewRef) { | |
return; | |
} | |
this.viewRef.detach(); | |
this.isDetached = true; | |
} | |
private ensureViewCreated() { | |
if (!this.templateRef) { | |
return; | |
} | |
if (!this.viewRef) { | |
this.viewRef = this.viewContainer.createEmbeddedView(this.templateRef, this._context); | |
this.viewRef.detach(); | |
} | |
} | |
private ensureViewAttached() { | |
if (!this.templateRef) { | |
return; | |
} | |
this.ensureViewCreated(); | |
if (this.isDetached && this.initialized) { | |
this.viewRef.reattach(); | |
this.isDetached = false; | |
} | |
} | |
handleLoadedState() { | |
if (!this.viewRef) { | |
return; | |
} | |
this.loadedSubscription?.unsubscribe(); | |
this.loadedSubscription = new Subscription(); | |
this.viewRef.rootNodes.forEach((node: unknown) => { | |
if (node instanceof View) { | |
if (this.isVisible && !node.isLoaded && node.isLoaded !== node.parent?.isLoaded) { | |
node.callLoaded(); | |
} else if (!this.isVisible && node.isLoaded && this._unloadWhenHidden) { | |
node.callUnloaded(); | |
} else { | |
this.loadedSubscription.add( | |
fromNativeScriptEventTarget(node, 'loaded').subscribe(() => { | |
this.handleLoadedState(); | |
}) | |
); | |
} | |
} | |
}); | |
} | |
ngOnDestroy(): void { | |
this.loadedSubscription?.unsubscribe(); | |
} | |
/** | |
* Asserts the correct type of the context for the template that `NgIf` will render. | |
* | |
* The presence of this method is a signal to the Ivy template type-check compiler that the | |
* `NgIf` structural directive renders its template with a specific context type. | |
*/ | |
static ngTemplateContextGuard<T>( | |
dir: nsIfDirective<T>, | |
ctx: any | |
): ctx is NsIfContext<Exclude<T, false | 0 | '' | null | undefined>> { | |
return true; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment