为了账号安全,请及时绑定邮箱和手机立即绑定

Angular中使用指令实现类似Figma的数字输入框

如果你熟悉 Figma,你会发现输入字段支持通过拖动来调整数值。你不再需要点击输入框输入数字,这个拖动功能非常实用,你可以轻松通过拖动直接得到你想要的数值。

我们可以用 Angular 指令来构建类似的东西。在这个实验里,我们会用到 Angular 的最新特性。

Figma number input

让我们看看怎么建这个。

实际上,我们可以用多种方式来实现这个目标。我们将用指令来构建它,采取一种非常通用的方法。这样一来,我们可以重用这些逻辑,比如调整元素大小或侧边栏等。

清洁剂指令 - 主要功能

输入的主要逻辑可以提取并封装成一个指令(或命令),主要目标是监听并响应鼠标事件,然后将鼠标移动转换为可用的数值。具体来说:

当用户按下鼠标(鼠标按下事件)时。

  1. 我们开始监听到鼠标移动事件,并将它们转换成可用的数值。

    当用户松开鼠标时,我们就不再监听。

我们将使用 rxjs 来稍微简化逻辑。

这里就是伪代码的写法。

    const mousedown$ = fromEvent<MouseEvent>(target, 'mousedown');
    const mousemove$ = fromEvent<MouseEvent>(document, 'mousemove');
    const mouseup$ = fromEvent<MouseEvent>(document, 'mouseup');

    let startX = 0;
    let step = 1;

    mousedown$
      .pipe(
         tap((event) => {
           startX = event.clientX; // 鼠标按下时的初始X坐标
        }),
        switchMap(() => mousemove$.pipe(takeUntil(mouseup$))))
      .subscribe((moveEvent) => {
        const delta = startX - moveEvent.clientX;
        const newValue = Math.round(拖动开始时的初始值 + delta);
      });

全屏 退出全屏

查看上述代码,应该很容易看出来发生了什么。我们基本上保存了初始的 clientX 值,这是鼠标点击时 X 轴的坐标。有了这些信息之后,当用户移动鼠标时,我们就可以计算出从初始位置到当前位置的 X 轴偏移值。

我们可以进一步添加更多自定义设置,例如:

  1. 灵敏度 - 灵敏度决定拖动距离和最终值之间的关系。灵敏度越高,即使只是稍微移动,最终数值也会变得很大。

  2. 步幅 - 设置移动鼠标时的步进距离。如果步幅设为 1,则最终值会每次加减 1

  3. Min:最小值。

  4. Max - 最大发送值。

最终的指令会这样显示:

    @Directive({
      selector: "[scrubber]",
    })
    export class ScrubberDirective {
      // 定义公共只读的 scrubberTarget 为 input.required<HTMLDivElement>({ 别名为 "scrubber" });
      public readonly scrubberTarget = input.required<HTMLDivElement>({
        alias: "scrubber",
      });

      // 定义公共只读的 step 为 model<number>(1);
      public readonly step = model<number>(1);

      // 定义公共只读的 min 为 model<number>(0);
      public readonly min = model<number>(0);

      // 定义公共只读的 max 为 model<number>(100);
      public readonly max = model<number>(100);

      // 定义公共只读的 startValue 为 model(0);
      public readonly startValue = model(0);

      // 定义公共只读的 sensitivity 为 model(0.1);
      public readonly sensitivity = model(0.1);

      // 定义公共只读的 scrubbing 为 output<number>();
      public readonly scrubbing = output<number>();

      // 定义私有的 isDragging 为 signal(false);
      private isDragging = signal(false);

      // 定义私有的 startX 为 signal(0);
      private startX = signal(0);

      // 定义私有的 startValueAtTheTimeOfDrag 为 signal(0);
      private readonly startValueAtTheTimeOfDrag = signal(0);

      // 定义私有的 destroyRef 为 inject(DestroyRef);
      private readonly destroyRef = inject(DestroyRef);

      // 定义私有的 subs 为 Subscription 类型的可选值
      private subs?: Subscription;

      constructor() {
        // 当 effect 执行时,取消订阅,然后设置订阅
        effect(() => {
          this.subs?.unsubscribe();
          this.subs = this.setupMouseEventListener(this.scrubberTarget());
        });

        // 当 this.destroyRef 销毁时,移除 "resizing" 类,并取消订阅
        this.destroyRef.onDestroy(() => {
          document.body.classList.remove("resizing");
          this.subs?.unsubscribe();
        });
      }

      // 设置鼠标事件监听器
      private setupMouseEventListener(target: HTMLDivElement): Subscription {
        // 定义 mousedown$ 为 mousedown 事件
        const mousedown$ = fromEvent<MouseEvent>(target, "mousedown");
        // 定义 mousemove$ 为 mousemove 事件
        const mousemove$ = fromEvent<MouseEvent>(document, "mousemove");
        // 定义 mouseup$ 为 mouseup 事件
        const mouseup$ = fromEvent<MouseEvent>(document, "mouseup");

        // 返回 mousedown$,并设置鼠标按下时的处理函数
        return mousedown$
          .pipe(
            tap((event) => {
              // 设置 isDragging 为 true
              this.isDragging.set(true);
              // 设置 startX 为 event.clientX
              this.startX.set(event.clientX);
              // 设置 startValueAtTheTimeOfDrag 为 this.startValue()
              this.startValueAtTheTimeOfDrag.set(this.startValue());
              // 给 document.body 添加一个 "resizing" 类
              document.body.classList.add("resizing");
            }),
            // 切换鼠标移动事件
            switchMap(() =>
              mousemove$.pipe(
                takeUntil(
                  mouseup$.pipe(
                    // 设置 isDragging 为 false,并从 document.body 移除 "resizing" 类
                    tap(() => {
                      this.isDragging.set(false);
                      document.body.classList.remove("resizing");
                    })
                  )
                )
              )
            )
          )
          .subscribe((moveEvent) => {
            // 计算 delta 和敏感度补偿后的 delta
            const delta = moveEvent.clientX - this.startX();
            const deltaWithSensitivityCompensation = delta * this.sensitivity();

            // 计算新的值
            const newValue =
              Math.round(
                (this.startValueAtTheTimeOfDrag() +
                  deltaWithSensitivityCompensation) /
                  this.step()
              ) * this.step();

            // 发出 clampedValue 作为 this.scrubbing 的值
            this.emitChange(newValue);
            // 设置 startValue 为 newValue
            this.startValue.set(newValue);
          });
      }

      // 私有的 emitChange 函数
      private emitChange(newValue: number): void {
        // 定义常量 clampedValue 为 Math.min(Math.max(newValue, this.min()), this.max());
        const clampedValue = Math.min(Math.max(newValue, this.min()), this.max());
        // 发出 clampedValue 作为 this.scrubbing 的值
        this.scrubbing.emit(clampedValue);
      }
    }

全屏 退出全屏

使用方法:如何使用 scrubber 功能

现在我们已经有了这份指令,让我们看看我们该如何开始使用它。

    <div #scrubberTarget 
         [scrubber]="scrubberTarget"
         [startValue]="this.roundedNumericValue()"
         [min]="0"
         [max]="100"
         [step]="2"
         [sensitivity]="0.2"
         (scrubbing)="this.updateValue($event)">
    </div>

点击切换到全屏模式 点击切换出全屏模式

目前,我们将 scrubberTarget 输入标记为必须的,不过实际上,我们可以把它设置为可选的,并自动使用指令宿主元素的 elementRef.nativeElement,这样效果就跟之前一样。将 scrubberTarget 作为输入暴露出来,这样如果你希望将不同的元素设置为目标,就可以实现这一点。

我们也给 body 添加了一个 resizing 类,以便我们能够正确地设置调整大小的光标。

    .resizing {
      cursor: ew-resize;
      touch-action: none;
      -webkit-user-select: none;
      user-select: none;
    }

全屏 退出全屏

我们使用了 effect 来启动监听器,这样可以确保当目标元素发生变化时,我们会在新元素上重新设置监听器。

看看它的实际效果

Live

我们为 Angular 制作了一个超级简单的数值滑块组件,可以帮助我们构建类似 Figma 中的输入字段,让用户轻松与数值输入互动。

代码和示例

点击此链接查看示例代码:https://stackblitz.com/edit/figma-like-number-input-angular?file=src%2Fscrubber.directive.ts

联系我。

推特链接:Twitter

请在评论区留下你的想法。注意安全 ❤️

给我买个披萨"给我买个披萨呗"][https://www.buymeacoffee.com/adisreyaj "支持我买个披萨"]

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消