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 = 0
oldOffset: number | string = 0
isMoving = false
initOffset = 0
_value: number | string = 0.5
isOpen = 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 = val
this.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.value
if (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.expandValueCache
this.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 = null
rightTemplate?: TemplateRef<void> | null = null
topTemplate?: TemplateRef<void> | null = null
bottomTemplate?: 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`] = true
classes[`${this.prefix}-pane-moving`] = this.isMoving
return 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 = value
this.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 = val
this.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 = true
value = 0.3
expandChange(){
this.expand = !this.expand
}
triggerMoveHnadle(e){
console.log(e);
console.log(this.value);
}
}