如何实现类似雷达扫描的效果
=============

#### 问题现象

场景一：有一个扫描的功能按键，点击按键之后怎么实现一个从按键向外扩散的一个圆形水波特效。

场景二：如何实现向圆周扫描360°旋转的雷达动画？

#### 背景知识

*   [层叠布局](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-layout-development-stack-layout)用于在屏幕上预留一块区域来显示组件中的元素，提供元素可以重叠的布局。层叠布局通过Stack容器组件实现位置的固定定位与层叠，容器中的子元素依次入栈，后一个子元素覆盖前一个子元素，子元素可以叠加，也可以设置位置。[animateTo](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-uicontext-uicontext#animateto)用于显式动画接口。在需要动画时，显式调用该接口改变状态以产生动画。
*   [Canvas](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-components-canvas-canvas)是提供画布的组件，用于自定义绘制图形。其基于[CanvasRenderingContext2D](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-canvasrenderingcontext2d)进行绘制，绘制对象可以是矩形、文本、图片等。

#### 解决方案

*   **场景一**：水波纹。
    
    使用Stack组件来给按钮添加类似水波纹扩散效果，点击按钮，添加动画，效果为透明度0.8->0，半径扩大到6倍，持续时间无限。再次点击按钮，则用持续时间为0的动画来打断持续时间无限的动画。
    
    import { Scale } from '@kit.ArkUI';
    
    @Entry
    @Component
    struct scan\_effect\_test {
      @State isListening: boolean \= false;
      uiContext: UIContext | undefined \= undefined;
    
      aboutToAppear() {
        this.uiContext \= this.getUIContext();
      }
    
      build() {
        Column() {
          ButtonWithWaterRipples({ isListening: this.isListening });
        }
        .backgroundColor(Color.Black)
        .justifyContent(FlexAlign.Center)
        .width('100%')
        .height('100%');
      }
    }
    
    @Component
    struct ButtonWithWaterRipples {
      @Link isListening: boolean;
      @State immediatelyOpacity: number \= 0.8; _// 立刻触发动画的Stack初始透明度_
      @State immediatelyScale: Scale \= { x: 1, y: 1 }; _//_ _立刻触发动画的Stack初始缩放度_
      private readonly BUTTON\_SIZE: number \= 120; _//_ _按钮大小_
      private readonly BUTTON\_CLICK\_SCALE: number \= 0.8; _//_ _按钮点击时缩放比例_
      private readonly ANIMATION\_DURATION: number \= 1300; _//_ _动画持续时间_
    
      @Styles
      ripplesStyle() {
        .width(this.BUTTON\_SIZE \* this.BUTTON\_CLICK\_SCALE)
        .height(this.BUTTON\_SIZE \* this.BUTTON\_CLICK\_SCALE)
        .borderRadius(this.BUTTON\_SIZE \* this.BUTTON\_CLICK\_SCALE / 2)
        .backgroundColor(Color.White);
      }
    
      build() {
        Stack() {
          Stack()
            .ripplesStyle()
            .opacity(this.immediatelyOpacity)
            .scale(this.immediatelyScale);
          Button() {
            Text(this.isListening ? '结束' : '开始')
              .fontSize(30);
          }
          .clickEffect({ level: ClickEffectLevel.HEAVY, scale: this.BUTTON\_CLICK\_SCALE })
          .backgroundColor($r('app.color.start\_window\_background'))
          .type(ButtonType.Circle)
          .width(this.BUTTON\_SIZE)
          .height(this.BUTTON\_SIZE)
          .zIndex(1)
          .onClick(() \=\> {
            this.isListening \= !this.isListening;
            if (this.isListening) {
             _ // 生成透明度0.8-\>0和扩大1-\>6倍半径的动画，iterations设置为\-1表示无限重复_
              this.getUIContext()?.animateTo({ duration: this.ANIMATION\_DURATION, iterations: -1, curve: Curve.EaseInOut },
                () \=\> {
                  this.immediatelyOpacity \= 0;
                  this.immediatelyScale \= { x: 6, y: 6 };
                });
            } else {
             _ // 设置duration为0动画打断扩散的特效_
              this.getUIContext()?.animateTo({ duration: 0 }, () \=\> {
                this.immediatelyOpacity \= 0.8;
                this.immediatelyScale \= { x: 1, y: 1 };
              });
            }
          });
        };
      }
    }
    
    效果预览：
    
    ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/45/v3/54mIEd72TL2nrhv-Ep2mXA/zh-cn_image_0000002628599992.gif?HW-CC-KV=V1&HW-CC-Date=20260709T133944Z&HW-CC-Expire=86400&HW-CC-Sign=E6C25B0E2B1DC59BF5DE61D7EEB30E4FF5C301E210ABE010E90EE5E0D6813B1F "点击放大")
    
*   **场景二**：雷达扫描。
    
    *   雷达扫描方案一：通过Stack组件叠加静态底图与动态旋转扇形图，并配合animation设置无限循环的线性动画（iterations: -1），使扇形图持续旋转实现。
        
        @Entry
        @Component
        struct RadarScan2 {
          @State angle: number \= 0; _// 旋转角度变量_
        
          aboutToAppear() {
            _//_ _初始化动画（2秒完成360度旋转）_
            setTimeout(() \=\> {
              this.angle \= 360;
            }, 200);
          }
        
          build() {
            Row() {
              Stack() {
               _ // 静态网格底图_
                Image($r('app.media.startIcon'))
                  .width(300)
                  .height(300);
        
               _ // 动态扇形扫描图_
                Image($r('app.media.background'))
                  .width(300)
                  .height(300)
                  .rotate({ z: 1, angle: this.angle })  _//_ _绕Z轴旋转_
                  .animation({
                    duration: 2000,
                    curve: Curve.Linear,
                    iterations: -1  _// 无限循环_
                  });
              };
            }
            .justifyContent(FlexAlign.Center)
            .backgroundColor(0x111111) _// 深色背景增强视觉效果_
            .width('100%')
            .height('100%');
          }
        }
        
        效果预览：
        
        ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/15/v3/f6oPx_iwSKWeq4ap79ddBw/zh-cn_image_0000002628759922.gif?HW-CC-KV=V1&HW-CC-Date=20260709T133944Z&HW-CC-Expire=86400&HW-CC-Sign=BC5F1E71BB9F6DAD9F45CF0057F2619C1E8B25F9C432D4476D5D6A65AAD0869D "点击放大")
        
    *   雷达扫描方案二：通过Canvas组件实现雷达扫描动画效果，包含网格绘制、扫描线旋转、扇形渐变区域和随机目标点。并使用定时器驱动角度变化实现动画效果，createRadialGradient创建扫描扇形渐变效果。
        
        import cryptoFramework from '@ohos.security.cryptoFramework';
        
        @Entry
        @Component
        struct RadarScan3 {
          @State @Watch('onDraw') angle: number \= 0;
          private centerX: number \= 150;
          private centerY: number \= 150;
          private radius: number \= 120;
          private settings: RenderingContextSettings \= new RenderingContextSettings(true);
          private context: CanvasRenderingContext2D \= new CanvasRenderingContext2D(this.settings);
          private random: cryptoFramework.Random \= cryptoFramework.createRandom(); _//_ _生成随机数实例_
        
          build() {
            Column() {
              Canvas(this.context)
                .width(300)
                .height(300)
                .backgroundColor('#001a33')
                .onReady(() \=\> {
                  this.onDraw();
                });
            }
            .width('100%')
            .height('100%')
            .justifyContent(FlexAlign.Center)
        
            .onAppear(() \=\> {
              setInterval(() \=\> {
                this.angle \= (this.angle \+ 2) % 360;
              }, 50);
            });
          }
        
          onDraw() {
            let ctx \= this.context;
            ctx.clearRect(0, 0, 300, 300); _//_ _清除画布_
        
            _//_ _绘制雷达网格_
            ctx.strokeStyle \= '#00ff00';
            ctx.lineWidth \= 1;
            for (let r \= 30; r <\= this.radius; r += 30) {
              ctx.beginPath();
              ctx.arc(this.centerX, this.centerY, r, 0, Math.PI \* 2);
              ctx.stroke();
            }
        
            _// 绘制十字坐标线_
            ctx.beginPath();
            ctx.moveTo(this.centerX, 30);
            ctx.lineTo(this.centerX, 270);
            ctx.moveTo(30, this.centerY);
            ctx.lineTo(270, this.centerY);
            ctx.stroke();
        
           _ // 绘制扫描线_
            const radian \= this.angle \* Math.PI / 180;
            ctx.beginPath();
            ctx.moveTo(this.centerX, this.centerY);
            ctx.lineTo(
              this.centerX \+ this.radius \* Math.cos(radian),
              this.centerY \+ this.radius \* Math.sin(radian)
            );
            ctx.strokeStyle \= '#00ff00';
            ctx.lineWidth \= 2;
            ctx.stroke();
        
           _ // 绘制扫描扇形_
            const gradient \= ctx.createRadialGradient(
              this.centerX, this.centerY, 0,
              this.centerX, this.centerY, this.radius
            );
            gradient.addColorStop(0, 'rgba(0,255,0,0.3)');
            gradient.addColorStop(1, 'rgba(0,255,0,0)');
            ctx.fillStyle \= gradient;
            ctx.beginPath();
            ctx.moveTo(this.centerX, this.centerY);
            ctx.arc(
              this.centerX, this.centerY,
              this.radius,
              radian \- Math.PI / 6,
              radian \+ Math.PI / 6
            );
            ctx.closePath();
            ctx.fill();
            this.drawRandomTargets(ctx); _//_ _随机目标点_
          }
        
          drawRandomTargets(ctx: CanvasRenderingContext2D) {
            let randData \= this.random.generateRandomSync(3);
            let speed \= Math.floor(randData.data\[0\] / 255);
            for (let i \= 0; i < 5; i++) {
              const r \= speed \* this.radius;
              const a \= speed \* Math.PI \* 2;
              const x \= this.centerX \+ r \* Math.cos(a);
              const y \= this.centerY \+ r \* Math.sin(a);
        
              ctx.beginPath();
              ctx.arc(x, y, 3, 0, Math.PI \* 2);
              ctx.fillStyle \= '#ff0000';
              ctx.fill();
            }
          }
        }
        
        效果预览：
        
        ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/91/v3/3dJxLgdtSFSNQ9Gf3kGgmg/zh-cn_image_0000002658959237.gif?HW-CC-KV=V1&HW-CC-Date=20260709T133944Z&HW-CC-Expire=86400&HW-CC-Sign=BBB0AA668096C5DF7C1AC70B2A3EF080616AC863C31E93253AE4C215F99641C0 "点击放大")
        
    
    *   雷达扫描方案三：通过Row的linearGradient属性创建扇形渐变效果，结合rotate动画实现扫描效果。渐变颜色使用半透明绿色模拟雷达波束，Stack容器实现图层叠加。
        
        @Entry
        @Component
        struct RadarScan4 {
          @State angle: number \= 0;
          private timerID: number \= -1;
          private colors: Array<\[string, number\]\> = \[
            \['rgba(0,255,0,0.1)', 0\],
            \['rgba(0,255,0,0.8)', 0.5\],
            \['rgba(0,255,0,0.1)', 1\]
          \];
        
          aboutToAppear() {
            this.timerID \= setInterval(() \=\> {
              this.angle \= (this.angle \+ 2) % 360;
            }, 50);
          }
        
          aboutToDisappear() {
            clearInterval(this.timerID);
          }
        
          build() {
            Column() {
              Stack() {
              _  // 静态网格背景_
                Row() {
                _  // 这里可以添加网格绘制逻辑_
                }
                .width(300)
                .height(300)
                .borderRadius(150)
                .backgroundColor('#4A90E2');
        
              _  // 扫描扇形（使用渐变Row模拟）_
                Row()
                  .width(150)
                  .height(3)
                  .margin({ left: 150 })
                  .linearGradient({
                    angle: this.angle,
                    colors: this.colors
                  })
                  .rotate({ angle: this.angle, centerX: 0, centerY: '50%' });
              }
              .backgroundColor(0x111111);
            }
            .width('100%')
            .height('100%')
            .justifyContent(FlexAlign.Center);
        
          }
        }
        
        效果预览：
        
        ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/3c/v3/zkHcHCYbSpaqwpME-xIRGA/zh-cn_image_0000002658839285.gif?HW-CC-KV=V1&HW-CC-Date=20260709T133944Z&HW-CC-Expire=86400&HW-CC-Sign=D7B79FD4EDF00A49151E2DA4152989220F1FEDC6C9EE591F5A6AD87493CF9FC6 "点击放大")
        
*   整体方案对比：
    
    | 
    方案
    
     | 
    
    优点
    
     | 
    
    缺点
    
     |
    | :-- | :-- | :-- |
    | 
    
    Image组件
    
     | 
    
    实现简单，性能开销小
    
     | 
    
    动态效果有限
    
     |
    | 
    
    Canvas绘制
    
     | 
    
    可定制扫描轨迹
    
     | 
    
    代码复杂度高
    
     |
    | 
    
    渐变属性
    
     | 
    
    无需图片资源
    
     | 
    
    视觉效果较单一
    
     |