Angular组件(一) 分割面板ShrinkSplitter
前言
分割面板在日常开发中经常使用,可将一片区域,分割为可以拖拽整宽度或高度的两部分区域。模仿iview的分割面板组件,用angular实现该功能,支持拖拽和[(ngModel)]
双向绑定的方式控制区域的展示收起和拖拽功能。
module.ts
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { TlShrinkSplitterComponent } from "./shrink-splitter.component";
import{NzToolTipModule} from "ng-zorro-antd/tooltip"const COMMENT = [TlShrinkSplitterComponent];@NgModule({declarations: [...COMMENT],exports: [...COMMENT],imports: [CommonModule,NzToolTipModule,]
})
export class TlShrinkSplitterModule {}
component.ts
import { AfterContentInit, AfterViewInit, ChangeDetectorRef, Component, ContentChildren, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, QueryList, TemplateRef, ViewChild } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { TlTemplateDirective } from "topdsm-lib/common"
import { isFalsy } from "topdsm-lib/core/util";
import { off, on } from "./util";@Component({selector: "tl-shrink-splitter",templateUrl: "./shrink-splitter.component.html",providers: [{provide: NG_VALUE_ACCESSOR,useExisting: forwardRef(() => TlShrinkSplitterComponent),multi: true}],host: {class: "tl-shrink-splitter",'[class.expand]': 'tlExpand','[class.contract]': '!tlExpand','[class.contract-left]': 'tlColsedMode === "left"','[class.contract-right]': 'tlColsedMode === "right"','[class.contract-top]': 'tlColsedMode === "top"','[class.contract-bottom]': 'tlColsedMode === "bottom"','[style.z-index]': 'tlZIndex',}
})
export class TlShrinkSplitterComponent implements OnInit, AfterContentInit, AfterViewInit, ControlValueAccessor {prefix = "tl-shrink-splitter"offset = 0oldOffset: number | string = 0isMoving = falseinitOffset = 0_value: number | string = 0.5isOpen = true@Input()tlZIndex = 10// @Input()// tlMode: "horizontal" | "vertical" = "horizontal"/** 是否展示收起icon */@Input()tlShowExpandIcon = true/** 收起容器模式,上下左右哪一个容器应用收起展开的状态 */@Input()tlColsedMode: "left" | "right" | "top" | "bottom" = "left"@Input()tlMin = "40px"@Input()tlMax = "40px"@Input()tlExpandTooltipContent = ""@Input()tlContractTooltipContent = ""get value() {return this._value}set value(val: number | string) {this._value = valthis.onChange(val)this.computeOffset()}expandValueCache: string | number = 0/** 展开状态 */get tlExpand() {return this.isOpen;}@Input()set tlExpand(val: boolean) {if (val !== this.isOpen) {this.isOpen = val;this.tlExpandChange.emit(val);this.changeExpand(val)}}/** 容器展开状态切换 */changeExpand(status: boolean) {if (!status) {// 收起this.expandValueCache = this.valueif (this.tlColsedMode === "left") {this.value = 0} else if (this.tlColsedMode === "right") {this.value = 1} else if (this.tlColsedMode === "top") {this.value = 0} else if (this.tlColsedMode === "bottom") {this.value = 1}} else {// 展开this.value = this.expandValueCachethis.expandValueCache = 0}}/** 展开收缩切换事件 */@Output() readonly tlExpandChange = new EventEmitter<boolean>();@Output() readonly onMoveStart = new EventEmitter();@Output() readonly onMoving = new EventEmitter<MouseEvent>();@Output() readonly onMoveEnd = new EventEmitter();expandChange(e: MouseEvent) {e.stopPropagation();e.preventDefault()this.tlExpand = !this.isOpen}@ContentChildren(TlTemplateDirective)templates?: QueryList<TlTemplateDirective>leftTemplate?: TemplateRef<void> | null = nullrightTemplate?: TemplateRef<void> | null = nulltopTemplate?: TemplateRef<void> | null = nullbottomTemplate?: TemplateRef<void> | null = null@ViewChild('outerWrapper')outerWrapper: ElementRef;get isHorizontal() {//return this.tlMode === 'horizontal';return this.tlColsedMode === "left" || this.tlColsedMode === "right"}get computedMin() {return this.getComputedThresholdValue('tlMin');}get computedMax() {return this.getComputedThresholdValue('tlMax');}get anotherOffset() {return 100 - this.offset;}get valueIsPx() {return typeof this.value === 'string';}get offsetSize() {return this.isHorizontal ? 'offsetWidth' : 'offsetHeight';}get paneClasses() {let classes = {}classes[`${this.prefix}-pane`] = trueclasses[`${this.prefix}-pane-moving`] = this.isMovingreturn classes}/** 展开收起触发器icon */get triggrrClass() {let classes = {}if (this.tlColsedMode === "left" && this.isOpen) {classes["icon-caret-left"] = true} else if (this.tlColsedMode === "left" && !this.isOpen) {classes["icon-caret-right"] = true} else if (this.tlColsedMode === "right" && this.isOpen) {classes["icon-caret-right"] = true} else if (this.tlColsedMode === "right" && !this.isOpen) {classes["icon-caret-left"] = true} else if (this.tlColsedMode === "top" && this.isOpen) {classes["icon-caret-left"] = true} else if (this.tlColsedMode === "top" && !this.isOpen) {classes["icon-caret-right"] = true} else if (this.tlColsedMode === "bottom" && this.isOpen) {classes["icon-caret-right"] = true} else if (this.tlColsedMode === "bottom" && !this.isOpen) {classes["icon-caret-left"] = true}return classes}get tooltipPosition() {let position = "right"if (this.tlColsedMode === "right" && !this.isOpen) {position = "left"}return position}get tooltipContent() {let tooltip = ""if (this.tlColsedMode === "left" && this.isOpen) {tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起左侧内容" : this.tlExpandTooltipContent} else if (this.tlColsedMode === "left" && !this.isOpen) {tooltip = isFalsy(this.tlContractTooltipContent) ? "展开左侧内容" : this.tlContractTooltipContent} else if (this.tlColsedMode === "right" && this.isOpen) {tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起右侧内容" : this.tlExpandTooltipContent} else if (this.tlColsedMode === "right" && !this.isOpen) {tooltip = isFalsy(this.tlContractTooltipContent) ? "展开右侧内容" : this.tlContractTooltipContent} else if (this.tlColsedMode === "top" && this.isOpen) {tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起顶部内容" : this.tlExpandTooltipContent} else if (this.tlColsedMode === "top" && !this.isOpen) {tooltip = isFalsy(this.tlContractTooltipContent) ? "展开顶部内容" : this.tlContractTooltipContent} else if (this.tlColsedMode === "bottom" && this.isOpen) {tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起底部内容" : this.tlExpandTooltipContent} else if (this.tlColsedMode === "bottom" && !this.isOpen) {tooltip = isFalsy(this.tlContractTooltipContent) ? "展开底部内容" : this.tlContractTooltipContent}return tooltip}px2percent(numerator: string | number, denominator: string | number) {return parseFloat(numerator + "") / parseFloat(denominator + "");}computeOffset() {this.offset = (this.valueIsPx ? this.px2percent(this.value as string, this.outerWrapper.nativeElement[this.offsetSize]) : this.value) as number * 10000 / 100}getComputedThresholdValue(type) {let size = this.outerWrapper.nativeElement[this.offsetSize];if (this.valueIsPx) return typeof this[type] === 'string' ? this[type] : size * this[type];else return typeof this[type] === 'string' ? this.px2percent(this[type], size) : this[type];}getMin(value1, value2) {if (this.valueIsPx) return `${Math.min(parseFloat(value1), parseFloat(value2))}px`;else return Math.min(value1, value2);}getMax(value1, value2) {if (this.valueIsPx) return `${Math.max(parseFloat(value1), parseFloat(value2))}px`;else return Math.max(value1, value2);}getAnotherOffset(value) {let res: string | number = 0;if (this.valueIsPx) res = `${this.outerWrapper.nativeElement[this.offsetSize] - parseFloat(value)}px`;else res = 1 - value;return res;}handleMove = (e) => {let pageOffset = this.isHorizontal ? e.pageX : e.pageY;let offset = pageOffset - this.initOffset;let outerWidth = this.outerWrapper.nativeElement[this.offsetSize];let value: string | number = ""if (this.valueIsPx) {value = `${parseFloat(this.oldOffset as string) + offset}px`} else {value = this.px2percent(outerWidth * (this.oldOffset as number) + offset, outerWidth)}let anotherValue = this.getAnotherOffset(value);if (parseFloat(value + "") <= parseFloat(this.computedMin + "")) value = this.getMax(value, this.computedMin);if (parseFloat(anotherValue + "") <= parseFloat(this.computedMax)) value = this.getAnotherOffset(this.getMax(anotherValue, this.computedMax));e.atMin = this.value === this.computedMin;e.atMax = this.valueIsPx ? this.getAnotherOffset(this.value) === this.computedMax : (this.getAnotherOffset(this.value) as number).toFixed(5) === this.computedMax.toFixed(5);this.value = valuethis.onMoving.emit(e)}handleUp = (e) => {this.isMoving = false;off(document, 'mousemove', this.handleMove);off(document, 'mouseup', this.handleUp);this.onMoveEnd.emit()}onTriggerMouseDown(e) {this.initOffset = this.isHorizontal ? e.pageX : e.pageY;this.oldOffset = this.value;this.isMoving = true;on(document, 'mousemove', this.handleMove);on(document, 'mouseup', this.handleUp);this.onMoveStart.emit()}constructor(private cdr: ChangeDetectorRef) { }ngOnInit(): void {console.log("ngOnInit");}ngAfterViewInit(): void {console.log("ngAfterViewInit");this.computeOffset()}ngAfterContentInit() {this.templates?.forEach((item) => {switch (item.getType()) {case 'left':this.leftTemplate = item.template;break;case 'right':this.rightTemplate = item.template;break;case 'top':this.topTemplate = item.template;break;case 'bottom':this.bottomTemplate = item.template;break;default:this.leftTemplate = item.template;break;}});}// 输入框数据变化时onChange: (value: any) => void = () => null;onTouched: () => void = () => null;writeValue(val: number | string): void {if (val !== this.value) {this.value = valthis.computeOffset();this.cdr.markForCheck();}}// UI界面值发生更改,调用注册的回调函数registerOnChange(fn: any): void {this.onChange = fn;}// 在blur(等失效事件),调用注册的回调函数registerOnTouched(fn: any): void {this.onTouched = fn;}// 设置禁用状态setDisabledState?(isDisabled: boolean): void {}
}
TlTemplateDirective指令实现
import { Directive, Input, TemplateRef, ViewContainerRef } from "@angular/core";
import { NzSafeAny } from "topdsm-lib/core/types";@Directive({selector: '[tlTemplate]'
})
export class TlTemplateDirective {@Input('tlTemplate')name: string = "default"// @Input()// type: string = ""constructor(private viewContainer: ViewContainerRef, public template: TemplateRef<NzSafeAny>) {//this.template = templateRef;}ngOnInit(): void {this.viewContainer.createEmbeddedView(this.template)}getType() {return this.name;}
}
事件绑定、解绑
export const on = (function() {if (document.addEventListener) {return function(element, event, handler) {if (element && event && handler) {element.addEventListener(event, handler, false);}};} else {return function(element, event, handler) {if (element && event && handler) {element.attachEvent('on' + event, handler);}};}
})();export const off = (function() {if (document.removeEventListener) {return function(element, event, handler) {if (element && event) {element.removeEventListener(event, handler, false);}};} else {return function(element, event, handler) {if (element && event) {element.detachEvent('on' + event, handler);}};}
})();
component.html
<div [ngClass]="prefix + '-wrapper'" #outerWrapper><div [ngClass]="prefix + '-horizontal'" *ngIf="isHorizontal; else verticalSlot"><div class="left-pane" [ngStyle]="{right: anotherOffset + '%'}" [ngClass]="paneClasses"><ng-container *ngTemplateOutlet="leftTemplate"></ng-container></div><div [ngClass]="prefix + '-trigger-con'" [ngStyle]="{left: offset + '%'}" (mousedown)="onTriggerMouseDown($event)"><div ngClass="tl-shrink-splitter-trigger tl-shrink-splitter-trigger-vertical" ><!-- <span class="tl-shrink-splitter-trigger-bar-con vertical" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" [tTooltip]="tooltipContent" [tooltipPosition]="tooltipPosition" *ngIf="tlShowExpandIcon"></span> --><span class="tl-shrink-splitter-trigger-bar-con vertical" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" nz-tooltip [nzTooltipTitle]="tooltipContent" [nzTooltipPlacement]="tooltipPosition" *ngIf="tlShowExpandIcon"></span></div></div><div class="right-pane" [ngStyle]="{left: offset + '%'}" [ngClass]="paneClasses"><ng-container *ngTemplateOutlet="rightTemplate"></ng-container></div></div><ng-template #verticalSlot><div [ngClass]="prefix + '-vertical'" ><div class="top-pane" [ngStyle]="{bottom: anotherOffset + '%'}" [ngClass]="paneClasses"><ng-container *ngTemplateOutlet="topTemplate"></ng-container></div><div [ngClass]="prefix + '-trigger-con'" [ngStyle]="{top: offset + '%'}" (mousedown)="onTriggerMouseDown($event)"><div ngClass="tl-shrink-splitter-trigger tl-shrink-splitter-trigger-horizontal" ><!-- <span class="tl-shrink-splitter-trigger-bar-con horizontal" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" [tTooltip]="tooltipContent" [tooltipPosition]="tooltipPosition" *ngIf="tlShowExpandIcon"></span> --><span class="tl-shrink-splitter-trigger-bar-con horizontal" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" nz-tooltip [nzTooltipTitle]="tooltipContent" [nzTooltipPlacement]="tooltipPosition" *ngIf="tlShowExpandIcon"></span></div></div><div class="bottom-pane" [ngStyle]="{top: offset + '%'}" [ngClass]="paneClasses"><ng-container *ngTemplateOutlet="bottomTemplate"></ng-container></div></div></ng-template>
</div>
component.less
@split-prefix-cls: ~"tl-shrink-splitter";
@trigger-bar-background: rgba(23, 35, 61, 0.25);
@trigger-background: #f8f8f9;
@trigger-width: 8px;
@trigger-bar-width: 4px;
@trigger-bar-offset: (@trigger-width - @trigger-bar-width) / 2;
@trigger-bar-interval: 3px;
@trigger-bar-weight: 1px;
@trigger-bar-con-height: 20px;
.tl-shrink-splitter{position: relative;height: 100%;width: 100%;
}
.tl-shrink-splitter-wrapper{position: relative;height: 100%;width: 100%;
}.@{split-prefix-cls}{background-color: #fff;border: 1px solid #dee2e6;&-pane{position: absolute;transition: all .3s ease-in;padding: 8px;&.tl-shrink-splitter-pane-moving{transition: none;}&.left-pane, &.right-pane {top: 0;bottom: 0;}&.left-pane {left: 0;}&.right-pane {right: 0;padding-left: 16px;}&.top-pane, &.bottom-pane {left: 0;right: 0;}&.top-pane {top: 0;}&.bottom-pane {bottom: 0;padding-top: 16px;}&-moving{-webkit-user-select: none;-moz-user-select: none;-ms-user-select: none;user-select: none;}}&-trigger{border: 1px solid #dcdee2;&-con {position: absolute;transform: translate(-50%, -50%);z-index: 10;}&-bar-con {position: absolute;overflow: hidden;&:hover{color: #000 !important;}&.vertical {top: 50%;left: -6px;width: 20px;height: @trigger-bar-con-height;background-color: #fff;border: 1px solid #ccc;border-radius: 50%;display: flex;align-items: center;justify-content: center;color: #b2b2b2;font-size: 14px;cursor: pointer;}&.horizontal {left: 50%;top: -4px;width: @trigger-bar-con-height;height: 20px;//transform: translate(-50%, 0);background-color: #fff;border: 1px solid #ccc;border-radius: 50%;display: flex;align-items: center;justify-content: center;color: #b2b2b2;font-size: 14px;cursor: pointer;}}&-vertical {width: @trigger-width;height: 100%;background: @trigger-background;border-top: none;border-bottom: none;cursor: col-resize;.@{split-prefix-cls}-trigger-bar {width: @trigger-bar-width;height: 1px;background: @trigger-bar-background;float: left;margin-top: @trigger-bar-interval;}}&-horizontal {height: @trigger-width;width: 100%;background: @trigger-background;border-left: none;border-right: none;cursor: row-resize;.@{split-prefix-cls}-trigger-bar {height: @trigger-bar-width;width: 1px;background: @trigger-bar-background;float: left;margin-right: @trigger-bar-interval;}}}&-horizontal {.@{split-prefix-cls}-trigger-con {top: 50%;height: 100%;width: 0;}}&-vertical {.@{split-prefix-cls}-trigger-con {left: 50%;height: 0;width: 100%;}}
}.tl-shrink-splitter.contract{.tl-shrink-splitter-trigger-vertical{width: 0;padding-left: 0;}.tl-shrink-splitter-trigger-horizontal{height: 0;padding-top: 0;}.tl-shrink-splitter-trigger{border: 0;}&.contract-left{.tl-shrink-splitter-pane.left-pane{width: 0;padding: 0;overflow: hidden;}.right-pane{padding-left: 8px;}}.tl-shrink-splitter-trigger-bar-con{&.vertical{left: -6px;}}&.contract-right{.tl-shrink-splitter-trigger-bar-con{&.vertical{left: -16px;}}}&.contract-top{.tl-shrink-splitter-pane.top-pane{overflow: hidden;height: 0;padding: 0;}.bottom-pane{padding-top: 8px;}.tl-shrink-splitter-trigger-bar-con.horizontal{transform: rotate(90deg);}}&.contract-bottom{.tl-shrink-splitter-trigger-bar-con{&.horizontal{top: -16px;}}.tl-shrink-splitter-pane.bottom-pane{overflow: hidden;height: 0;padding: 0;}.top-pane{padding-top: 8px;}.tl-shrink-splitter-trigger-bar-con.horizontal{transform: rotate(90deg);}}
}
.tl-shrink-splitter.expand{.tl-shrink-splitter-trigger-bar-con{&.vertical{left: -8px;}}&.contract-top{.tl-shrink-splitter-trigger-bar-con.horizontal{transform: rotate(90deg);}}&.contract-bottom{.tl-shrink-splitter-trigger-bar-con.horizontal{transform: rotate(90deg);}}
}
页面效果
左右容器和上下容器
demo
import { Component } from '@angular/core';@Component({selector: 'tl-demo-shrink-splitter-basic',template: `<button tButton type="button" label="切换伸缩状态" class="ui-plusWidth ui-button-primary" style="margin-right: 8px" (click)="expandChange()"></button><div class="split-box"><tl-shrink-splitter [(tlExpand)]="expand" [(ngModel)]="value" (onMoving)="triggerMoveHnadle($event)"><ng-template tlTemplate="left"><div>左侧区域自定义</div></ng-template><ng-template tlTemplate="right"><div>右侧区域自定义</div></ng-template></tl-shrink-splitter> </div>`,styles: [`.split-box{height: 200px;display: flex;position: relative;overflow: hidden;}.split-right{margin-left: 10px;border: 1px solid #e3e3e3;flex:1;}`]
})
export class TlDemoShrinkSplitterBasicComponent {expand = truevalue = 0.3expandChange(){this.expand = !this.expand}triggerMoveHnadle(e){console.log(e);console.log(this.value); }
}