React Native 实现环形滑块

最近在项目中需要实现一个半圆环形的滑块组件用于实现温度的调节,基本的效果如下:

demo2.gif

要求可以控制开口的角度,滑块支持渐变的颜色。简单的思考过后,最终决定使用 svg 实现。

完整代码见Github

实现思路

组件基于 svg 实现,基本的思路是使用 path 绘制两个环形,其中一个作为底色,另一个用于标识当前值。在顶部环形末端通过 circle 绘制一个圆形用于拖拽控制,在圆上添加相应的手势函数,监听 move 事件计算出滑块值。

为了便于计算,先建立一个如下图所示的坐标系,其中极坐标系以逆时针方向为正方向:

coordinate.png

这样我们使用 r + r * sin(radian)r + r * cos(radian) 就可以计算出 xy 坐标(r 为半径,radian 为极角所对弧度,不考虑线宽)。

实现过程

在介绍完成了要达到的目标以及基本的思路之后,现在一步步的来进行实现。

绘制底部轨道

为了绘制出底部轨道,我们需要外部提供一些基本的参数:

  • radius:圆环半径
  • strokeWidth:线宽
  • openingRadian:开口弧度,为了便于计算值为实际开口弧度的一半
  • backgroundTrackColor:底部轨道颜色

根据圆环半径和线宽我们可以计算出 svg 的大小为 radius * 2 + strokeWidthradius 是圆心到线宽中间的距离)。

接着我们需要根据开头弧度和圆环半径计算出起点和终点的坐标,利用三角函数我们可以很容易的计算出极坐标系中任一弧度对应的点的直接坐标:

/**
 * 极坐标转笛卡尔坐标
 * @param {number} radian - 弧度表示的极角
 */
polarToCartesian(radian) {
  const { radius, strokeWidth } = this.props
  const baseSize = radius + strokeWidth / 2 // 基础大小为半径加上线宽
  const x = baseSize + radius * Math.sin(radian)
  const y = baseSize + radius * Math.cos(radian)
  return { x, y }
}

起始点的弧度为 2 * Math.PI - openingRadian,终点的弧度为 openingRadian

完成了必要的计算之后,我们就可以开始进行绘制了。首先使用 M 命令将画笔移动至起点,然后通过弧线命令 A 绘制一个圆弧,绘制的代码如下:

<Svg width={svgSize} height={svgSize}>
  <Path
    strokeWidth={strokeWidth}
    stroke={backgroundTrackColor}
    fill="none"
    d={`M${startPoint.x},${startPoint.y} A ${radius},${radius},0,${startRadian - openingRadian >= Math.PI ? '1' : '0'},1,${endPoint.x},${endPoint.y}`}
  />
</Svg>

在绘制圆弧时还需要考虑是画大角度还是小角度的情况,我们可以根据起点终点的弧度差进行计算。

最终的得到如下图所示的效果:

bg.png

我们可以将 stroke-linecap 属性设置 round,使端点显示一个以线宽为直径的半圆。

bg_round.png

绘制顶部轨道

顶部轨道的绘制和底部其实没有太大差别,主要是结束点的计算以及轨道颜色为渐变色。

首先我们来绘制渐变的轨道,使用的渐变色由外部通过属性传入:

  • linearGradient:渐变色,如:[{stop:'0%',color:'#1890ff'},{stop:'100%',color:'#f5222d'}]

SVG 中使用 linearGradient 定义线性渐变且必须嵌套在 <defs> 内部。通过渐变方向及多个停止点颜色来定义不同的渐变色:

<Defs>
  <LinearGradient
    x1="0%"
    y1="100%"
    x2="100%"
    y2="0%"
    id="gradient">
    {
      linearGradient.map((item, index) => (
        <Stop
          key={index}
          offset={item.stop}
          stopColor={item.color}
        />
      ))
    }
  </LinearGradient>
</Defs>

在使用时通过 url 函数应用渐变:

<Path
  strokeWidth={strokeWidth}
  stroke="url(#gradient)"
  fill="none"
  strokeLinecap="round"
  d={`M${startPoint.x},${startPoint.y} A ${radius},${radius},0,${startRadian - currentRadian >= Math.PI ? '1' : '0'},1,${curPoint.x},${curPoint.y}`}
/>

最终可以得到下面的效果(底色被覆盖了):

slider_track.png

顶部轨道停止点

要想获取顶部轨道停止点,我们先需要计算出停止点的弧度,停止点的弧度应该等于总弧度-当前值对应弧度+开口弧度

const currentRadian = (Math.PI - openingRadian) * 2 * (max - value) / (max - min) + openingRadian

滑动按钮

我们还需要在顶部轨道的末端绘制一个圆形按钮,拖动该按钮可以调整的值,其中按钮的半径和背景色由外部提供:

  • buttonRadius:按钮半径
  • buttonColor:按钮颜色

这里需要考虑按钮的直径大于圆环线宽的情况,这会影响到 svg 大小和坐标的计算,在计算时应该使用两者中较大的那个。

<Circle
  cx={curPoint.x}
  cy={curPoint.y}
  r={buttonRadius}
  fill={buttonColor}
  stroke={buttonColor}
/>

得到的效果如下:

circle.png

拖动滑块

当拖动滑块按钮时,我们需要根据拖动的位置计算出滑块当前的值。

滑块的值应该等于起点和当前点弧度差 * (最大值 - 最小值) / ((Math.PI - openingRadian) * 2)(不考虑极值情况),这里关键在于获取起点和当前点弧度差,结合前面的极坐标系,只要我们可以计算出当前点在极坐标系中的弧度,就可以计算出弧度差,从而得到当前值。当前值的计算方式如下:

/**
 * 根据弧度获取当前值
 * @param {*} radian 
 */
getCurrentValueByRadian(radian) {
  const { openingRadian, min, max } = this.props
  if (radian <= openingRadian) {
    return max
  }
  const radianDiff = 2 * Math.PI - openingRadian - radian
  if (radianDiff <= 0) {
    return min
  }
  return (max - min) * radianDiff / ((Math.PI - openingRadian) * 2)
}

那如何获取当前点的极角呢?通过反三角函数,我们可以得到当前点和圆心的连线与 X 轴或者 Y 轴之间的夹角,然后进行一些计算即可得到极角。这里关键是使用哪种反三角函数以及计算哪里的夹角?考虑到如果使用反正弦或者反余弦函数,得到同样的值的时候需要判断 x 的位置从而进行不同的计算。所以,最终决定根据反正切函数计算出连线与 X 轴的夹角,然后根据点在圆心左边还是右边,用 Math.PI * 3 / 2 或 Math.PI / 2 减去夹角来计算极角。

radian_c.png

如上图所示,先使用反正切函数计算出角 ⍺,然后用 Math.PI / 2 减去 ⍺,即可得到 A 点的极角。如果点在圆心的左边,则用 Math.PI * 3 / 2 去减即可:

/**
 * 笛卡尔坐标转极坐标
 * @param {*} x 
 * @param {*} y 
 */
cartesianToPolar(x, y) {
  const { radius } = this.props
  const distance = radius + this._getExtraSize() / 2 // 圆心距离坐标轴的距离
  if (x === distance) {
    return y > distance ? 0 : Math.PI / 2
  }
  const a = Math.atan((y - distance) / (x - distance)) // 计算点与圆心连线和 x 轴的夹角
  return (x < distance ? Math.PI * 3 / 2 : Math.PI / 2) - a
}

接下来,我们需要获取 A 点在直角坐标系中的坐标。首先,通过 onLayout 函数得到顶点的坐标,然后在 move 事件中获取当前点的 moveXmoveY,减去顶点的坐标:

_handlePanResponderMove = (e, gestureState) => {
  const x = gestureState.moveX
  const y = gestureState.moveY
  const radian = this.cartesianToPolar(x - this.vertexX, y - this.vertexY)
  const value = this.getCurrentValueByRadian(radian)
  this.setState({ value })
}

得到的效果如下图所示:

demo1.gif

从图中可以看到,已经可以完成基本的拖动功能了。但是,现在可以直接从最小值变为最大值,这不是期望的结果,应该只能沿着轨道进行拖动才行。这里只需要加一个判断,限制变化的幅度即可:

this.setState(({ value: curValue }) => {
  value = Math.abs(value - curValue) > 10 ? curValue : value
  return { value }
})

添加限制之后的效果如下:

demo2.gif
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272

推荐阅读更多精彩内容

  • Threejs 为什么? webGL太难用,太复杂! 但是现代浏览器都支持 WebGL 这样我们就不必使用 Fla...
    强某某阅读 5,849评论 1 21
  • echarts 原生是不支持弧形渐变的,本文只是取巧,利用线性渐变 linear 实现视觉上的弧形渐变。适用于以弧...
    KrisLeeSH阅读 16,202评论 0 3
  • Rough.js[https://roughjs.com/]是一个手绘风格的图形库,提供了一些基本图形的绘制能力,...
    街角小林2阅读 333评论 0 0
  • 关于专题【vue开发音乐App】 本篇介绍底部快捷控件的环形进度条的实现方法:通过svg绘制两个环,线条模糊的环作...
    大海爱奔跑阅读 769评论 0 0
  • 第一章 HTML5 (2014年10月29日发布)新特性: 10个 (1)新的语义标签 (2)增强型表单 (3)视...
    fastwe阅读 912评论 0 1