├── pics ├── cover.jpg └── 一起撸个甜甜圈 │ ├── 1.jpg │ ├── 2.png │ ├── 3.jpg │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── 7.png │ ├── 8.png │ ├── 9.png │ ├── 10.png │ ├── 11.png │ ├── 12.png │ ├── 13.png │ ├── 14.png │ ├── 15.png │ ├── 16.jpg │ ├── 16.png │ ├── 17.png │ └── 2018-09-25_170905.png ├── README.md ├── 一起撸个甜甜圈.md ├── 一起撸个甜甜圈-codever.md └── 亲,还在为PopupWindow烦恼吗.md /pics/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/cover.jpg -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/1.jpg -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/2.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/3.jpg -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/4.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/5.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/6.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/7.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/8.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/9.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/10.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/11.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/12.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/13.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/14.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/15.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/16.jpg -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/16.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/17.png -------------------------------------------------------------------------------- /pics/一起撸个甜甜圈/2018-09-25_170905.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razerdp/Article/HEAD/pics/一起撸个甜甜圈/2018-09-25_170905.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Article 2 | // 个人文章存放地 3 | 4 | ![](https://raw.githubusercontent.com/razerdp/Article/3ca218d0fb3f05c77473f90173bf0004ee010cbb/pics/cover.jpg) 5 | 6 | * 一起撸系列(写作一起撸,实际单撸) 7 | * [**一起撸个甜甜圈吧**](https://github.com/razerdp/Article/blob/master/%E4%B8%80%E8%B5%B7%E6%92%B8%E4%B8%AA%E7%94%9C%E7%94%9C%E5%9C%88.md) 8 | * 其他 9 | * [**亲,还在为PopupWindow烦恼吗**](https://github.com/razerdp/Article/blob/master/%E4%BA%B2%EF%BC%8C%E8%BF%98%E5%9C%A8%E4%B8%BAPopupWindow%E7%83%A6%E6%81%BC%E5%90%97.md) 10 | 11 | -------------------------------------------------------------------------------- /一起撸个甜甜圈.md: -------------------------------------------------------------------------------- 1 | # 一起撸个甜甜圈吧 2 | 3 | **本文首发掘金(没写完orz),现重写并补全。** 4 | 5 | 在Android中,说到图表,我们往往都会选择找库,比如MPAndroidChart。 6 | 7 | 然而更多的时候,我们往往只需要某一类型的图表,为了这个类型的图表而不得不把整个库(包含所有图表逻辑)导入进来,还是感觉有点重的。 8 | 9 | 俗话说得好,自己动手,丰衣足食。 10 | 11 | 于是就有了今天的甜甜圈。(为何叫甜甜圈。。。不觉得环形饼图好像个甜甜圈吗哈哈) 12 | 13 | ![](https://github.com/razerdp/AnimatedPieView/blob/master/art/pie_click_effect.gif) 14 | 15 | --- 16 | 17 | ### 起源 18 | 作为程序员,一个新的需求/控件的起源,很多时候都是来源于产品,所以,这次控件的诞生,其实很简单,来源于一张优化点设计图 **(只截取部分,其余部分不宜公开)**: 19 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/1.jpg) 20 | 21 | 咋一看,这张图so easy啦~ 3只paint,3个颜色,随便画画,搞定~ 22 | 23 | 可是,不知道为啥,看着这个甜甜圈还挺漂亮的,不甘心就这么简单的画出来就完事。。。 24 | 25 | 于是心里有个魅惑的声音告诉我:“既然这个不急,只是优化点,那为何不优化彻底一点,做的比这货更漂亮呢” 26 | 27 | 被魅惑的我,立马熟悉的打开AS,新建Project,填上Name,弄个类继承View,然后。。。。 28 | 29 | 然后。。。他喵的然后怎么干啊!!!! 30 | 31 | ### 迷茫 32 | 33 | 相信很多人写自定义控件都会有这个疑惑。。。我建好类了~ 我继承好了View了~ 接下来,,,我不会做了TAT 34 | 35 | 其实不仅仅你们,就是我写过不少的控件,现在建好一个类,我也偶尔会有这个情况。。。(嗯,可能写的还是不够多)。 36 | 37 | 原因很简单:**所有的元素,都是心里所想,并没有做出一个具体的动画效果和预览,所以方向太多,一下子很迷茫。** 38 | 39 | 因为当我们想完成一个控件或者动画效果是,我们往往很快就能在心里确定好我们想要什么效果,然而当我们真的要实施的时候,就会发现似乎不知道该怎么把效果变成一行行的代码。 40 | 41 | 在迷茫的时候,我的做法是,把我的期望写下来: 42 | 43 | * 我的甜甜圈会动 44 | * 我的甜甜圈可配置参数要多(自由度要高) 45 | * 我的甜甜圈点击的时候要有个效果,至于效果,大概是浮上来呈现出Z轴上移的样子,最好能加上阴影 46 | * 我的甜甜圈要简单好上手。。。 47 | 48 | 当需求写了下来,我们就可以逐步击破。 49 | 50 | 针对上面的需求点,我们逐步确定我们的方案: 51 | 52 | * 甜甜圈 53 | * 继承View,自己画 54 | * 会动的甜甜圈 55 | * Animation走起,反正就是可以逐步计算进度并让我根据值来进行不同的绘制即可 56 | * 可配置多 57 | * 因为参数比较多,因此归并到一个config类里面,采取Builder模式,形成链式配置,保持清爽的编码风格 58 | * 点击效果 59 | * 甜甜圈可以通过大小变化来造成z轴上浮的伪效果,加上BlurMaskFilter或者ShadowLayer 60 | * 简单上手 61 | * 暴露的api尽可能少,以及面向接口编程 62 | 63 | ### 数据 64 | 65 | 受限于篇幅,这里仅贴出核心代码,其余地方以思路讲解为主。 66 | 67 | 首先由简入繁,我们先尝试画出一个简单的甜甜圈: 68 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/2.png) 69 | 70 | so easy~依然是开头所说的,3只笔,3个角度,完事。 71 | 72 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/3.jpg) 73 | 74 | 然而,如果我们允许外部配置更多的甜甜圈的话,那岂不是要修改这个类?如果有一个方法能够提供给我动态增删就好了。 75 | 76 | 说做就做,我们看一下目前甜甜圈需要的元素: 77 | 78 | * 颜色 79 | * 描述 80 | * 数据(计算比例用) 81 | 82 | 也就是说,如果我们跟用户约定一个方式,约定好由用户提供这些元素,我们仅负责渲染的话,那么我们就可以实现动态增删的需求了。 83 | 84 | 那么,该如何约定呢? 85 | 86 | 假如我们要求使用一个类,也就是用户必须传给我们这个类,那么对于用户来说,是一个麻烦的使用,因为我们想绘制在图标上的数据往往都是服务器请求回来的,如果想画出甜甜圈,还得做多一步转换,这无疑是增大了复杂度,也就不符合我们简单好用的期望了。 87 | 88 | 考虑到这个,我采取了接口的方式。接口约束要求返回以上三个元素,这样做的好处是不用破坏用户原来的数据,他甚至可以用原来的数据bean来实现甜甜圈的需求,另一个好处是我可以很方便的进行扩展。 89 | 90 | 事实上,这一点也得到了使用者的认可(ps,在国外本库被评为2018年初值得关注的25个库之一,因此国外issue提出的比较多,邮件联系的也是主要是外国友人): 91 | 92 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/4.png) 93 | 94 | 根据以上条件,我们初步定义出一个接口:IPieInfo 95 | 96 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/5.png) 97 | 98 | 在这之后,我们计算都可以依靠接口获取: 99 | 100 | * 获取颜色决定本段甜甜圈的取色 101 | * 获取数值来计算本段甜甜圈的 102 | * 获取描述来绘制甜甜圈的描述 103 | 104 | 这样做可以不限制用户的数据类型,只需要实现我们的接口并对各个方法进行返回即可。 105 | 106 | 在绘制的时候,我们把用户传入的数据存到一个List中,在存入的时候,根据获取的value进行计算,得到其开始角度和结束角度,并把数据包装在一个类里面供控件内部使用。而这一切,对外部来说都是不透明的,外部使用仅仅关注的是配置,而不是计算。 107 | 108 | ### 点击 109 | 110 | 甜甜圈的点击是一个比较麻烦的点,主要原因如下: 111 | 112 | * 甜甜圈支持起始角度设置,而对起始角度并没有做要求,也就是传入-3600°也是可以的 113 | * 点击的时候需要精确判定点击的区域在哪个甜甜圈里 114 | * 甜甜圈被点击后的动作,以及上一次点击的甜甜圈动画和本次点击的甜甜圈动画需要切换(一个还原一个上浮) 115 | 116 | 首先看看第一个问题,我们的甜甜圈虽然可以设置无限角度,但实际上其实归根结底可以归到0 ~ 360°之间,即便传入一个很大的值,其实也是一定倍数 * 360 + 偏移量而已,所以针对任意角度,我们需要将其收束到0 ~ 360°之间: 117 | 118 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/6.png) 119 | 120 | 在点击触发的时候,我们先判断点击的位置是否在甜甜圈(或者饼图)内,判断的方法也很简单,就是初中的技巧计算两点之间的直线距离。 121 | 122 | 我们获取触摸点的x,y,计算其到中心的距离,假如当前是甜甜圈模式(环形饼图),则需要甜甜圈内径≤距离≤甜甜圈外径则判定在甜甜圈内。 123 | 124 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/7.png) 125 | 126 | 计算完距离后,我们需要计算角度,我们通过角度来获取我们当前点击的是哪一段的甜甜圈。 127 | 128 | 目前我们已知点击的xy,以及中心点,这时候我们可以用atan2方法反计算出角度 129 | ```java 130 | //得到角度 131 | double touchAngle = Math.toDegrees(Math.atan2(y - centerY, x - centerX)); 132 | ``` 133 | 134 | 这里计算出来的是-180° ~ 180°范围内的值,即以x正半轴为起始,逆时针(1、2象限)则是-180° ~ 0,顺时针(3、4象限)是0 ~ 180°。而我们的甜甜圈在开始的时候也说过,是无限角度的,当然,我们处理到0 ~ 360°,然而即便收束了起来,还是与我们计算出来的-180° ~ 180°对不上,因此我们对计算出来的角度需要做一下处理。 135 | 136 | 在这里我选择当点击的角度小于0的时候,加上360°。这里可能会有小伙伴问我为什么不是加上180°,而是加360°。 137 | 138 | 这个问题很简单,因为我们的甜甜圈在转换为0 ~ 360°之后,在1、2象限表现的是180° ~ 360°的范围,而我们点击的角度在1、2象限是-180° ~ 0,如果加上180,则是0 ~ 180°,还是无法满足我们的甜甜圈判断,因此在数值小于0的情况下,我们加上360°。 139 | 140 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/8.png) 141 | 142 | 接着我们根据角度寻找每一段甜甜圈里匹配的角度进行查询,直到找到为止。 143 | 144 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/9.png) 145 | 146 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/10.png) 147 | 148 | 在查找的时候我们还需要注意转换为0 ~ 360的情况中有一种特殊情况,就是某段甜甜圈跨越了0和360的界限。比如说图中的情况: 149 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/11.png) 150 | 151 | 152 | 其他的部分比如点击的动画实现等,则是由Animator计算并不断重绘。这里就不再详细说明。 153 | 154 | 155 | ### 文字 156 | 157 | 文字的绘制相对简单,我们需要确定的是文字的位置就可以了。 158 | 159 | 从效果图上我们知道,文字绘制有个引导线,而文字要么在引导线上,要么在引导线下,要么上下都有。 160 | 161 | 为了扩展,此处我粗暴的给出了四种文字属性: 162 | 163 | * 文字都在引导线上 164 | * 文字都在引导线下 165 | * 文字在1、2象限在引导线上,在3、4象限处于引导线下 166 | * 文字与引导线对齐 167 | 168 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/16.jpg) 169 | 170 | 计算文字的位置首当其冲我们得确认文字所处象限,然而我们仅仅知道的条件只有这段甜甜圈的起始、结束角度、甜甜圈半径,因此我们需要把角度换算为距离。 171 | 172 | 那我们需要怎么做呢?实际上这也是三角函数的简单运用,根据效果图,文字指引线的起始点是在甜甜圈的中间,因此我们可以根据甜甜圈的中心点到某段甜甜圈的中间连线作为三角形的斜边,根据三角函数sin/cos求出x,y的坐标即可。 173 | 174 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/16.png) 175 | 176 | 求出了文字指引线的起始点,我们就清楚该文字属于哪个象限了,简单的判断x,y即可 177 | 178 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/17.png) 179 | 180 | 最后关于文字引导线的绘制,我们可以简单的PathMeasure使Path动态生长。 181 | 182 | ### 总结 183 | 184 | 对于甜甜圈,这个项目的主要难点在本篇已经说出,这个工程说复杂其实也不是很复杂,但说简单我个人认为也不能说分分钟完事,其实还是有挺多细节需要琢磨的。 185 | 186 | 诚然,这个库还是很有进步空间,比如issue里面所说的图例以及数据过多时的文字重叠等等(说起来,数据过多本就不适合饼图啊。。。。)。 187 | 188 | 但只要我还收到issue,我就不会放弃更新,一直迭代下去~ 189 | 190 | 更多的话就不多说了,欢迎大家阅览项目:https://github.com/razerdp/AnimatedPieView 191 | 192 | >同时也顺便推荐我的另一个项目:BasePopup:https://github.com/razerdp/BasePopup 193 | 194 | 欢迎交流哈-V- 195 | -------------------------------------------------------------------------------- /一起撸个甜甜圈-codever.md: -------------------------------------------------------------------------------- 1 | # 一起撸个甜甜圈吧 2 | 3 | **本文首发掘金(没写完orz),现重写并补全。** 4 | 5 | 在Android中,说到图表,我们往往都会选择找库,比如MPAndroidChart。 6 | 7 | 然而更多的时候,我们往往只需要某一类型的图表,为了这个类型的图表而不得不把整个库(包含所有图表逻辑)导入进来,还是感觉有点重的。 8 | 9 | 俗话说得好,自己动手,丰衣足食。 10 | 11 | 于是就有了今天的甜甜圈。(为何叫甜甜圈。。。不觉得环形饼图好像个甜甜圈吗哈哈) 12 | 13 | ![](https://github.com/razerdp/AnimatedPieView/blob/master/art/pie_click_effect.gif) 14 | 15 | --- 16 | 17 | ### 起源 18 | 作为程序员,一个新的需求/控件的起源,很多时候都是来源于产品,所以,这次控件的诞生,其实很简单,来源于一张优化点设计图 **(只截取部分,其余部分不宜公开)**: 19 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/1.jpg) 20 | 21 | 咋一看,这张图so easy啦~ 3只paint,3个颜色,随便画画,搞定~ 22 | 23 | 可是,不知道为啥,看着这个甜甜圈还挺漂亮的,不甘心就这么简单的画出来就完事。。。 24 | 25 | 于是心里有个魅惑的声音告诉我:“既然这个不急,只是优化点,那为何不优化彻底一点,做的比这货更漂亮呢” 26 | 27 | 被魅惑的我,立马熟悉的打开AS,新建Project,填上Name,弄个类继承View,然后。。。。 28 | 29 | 然后。。。他喵的然后怎么干啊!!!! 30 | 31 | ### 迷茫 32 | 33 | 相信很多人写自定义控件都会有这个疑惑。。。我建好类了~ 我继承好了View了~ 接下来,,,我不会做了TAT 34 | 35 | 其实不仅仅你们,就是我写过不少的控件,现在建好一个类,我也偶尔会有这个情况。。。(嗯,可能写的还是不够多)。 36 | 37 | 原因很简单:**所有的元素,都是心里所想,并没有做出一个具体的动画效果和预览,所以方向太多,一下子很迷茫。** 38 | 39 | 因为当我们想完成一个控件或者动画效果是,我们往往很快就能在心里确定好我们想要什么效果,然而当我们真的要实施的时候,就会发现似乎不知道该怎么把效果变成一行行的代码。 40 | 41 | 在迷茫的时候,我的做法是,把我的期望写下来: 42 | 43 | * 我的甜甜圈会动 44 | * 我的甜甜圈可配置参数要多(自由度要高) 45 | * 我的甜甜圈点击的时候要有个效果,至于效果,大概是浮上来呈现出Z轴上移的样子,最好能加上阴影 46 | * 我的甜甜圈要简单好上手。。。 47 | 48 | 当需求写了下来,我们就可以逐步击破。 49 | 50 | 针对上面的需求点,我们逐步确定我们的方案: 51 | 52 | * 甜甜圈 53 | * 继承View,自己画 54 | * 会动的甜甜圈 55 | * Animation走起,反正就是可以逐步计算进度并让我根据值来进行不同的绘制即可 56 | * 可配置多 57 | * 因为参数比较多,因此归并到一个config类里面,采取Builder模式,形成链式配置,保持清爽的编码风格 58 | * 点击效果 59 | * 甜甜圈可以通过大小变化来造成z轴上浮的伪效果,加上BlurMaskFilter或者ShadowLayer 60 | * 简单上手 61 | * 暴露的api尽可能少,以及面向接口编程 62 | 63 | ### 数据 64 | 65 | 受限于篇幅,这里仅贴出核心代码,其余地方以思路讲解为主。 66 | 67 | 首先由简入繁,我们先尝试画出一个简单的甜甜圈: 68 | 69 | ```java 70 | public class AnimatedPieView extends View { 71 | protected final String TAG = this.getClass().getSimpleName(); 72 | 73 | private Paint paint1; 74 | private Paint paint2; 75 | private Paint paint3; 76 | 77 | RectF mDrawRectf = new RectF(); 78 | 79 | 80 | //其他构造器忽略 81 | public AnimatedPieView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 82 | super(context, attrs, defStyleAttr); 83 | initView(context, attrs); 84 | } 85 | 86 | 87 | private void initView(Context context, AttributeSet attrs) { 88 | paint1 = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); 89 | paint1.setStyle(Paint.Style.STROKE); 90 | paint1.setStrokeWidth(80); 91 | paint1.setColor(Color.RED); 92 | 93 | paint2 = new Paint(paint1); 94 | paint2.setColor(Color.GREEN); 95 | 96 | paint3 = new Paint(paint1); 97 | paint3.setColor(Color.BLUE); 98 | 99 | } 100 | 101 | 102 | @Override 103 | protected void onDraw(Canvas canvas) { 104 | super.onDraw(canvas); 105 | 106 | final float width = getWidth() - getPaddingLeft() - getPaddingRight(); 107 | final float height = getHeight() - getPaddingTop() - getPaddingBottom(); 108 | 109 | canvas.translate(width / 2, height / 2); 110 | //半径 111 | final float radius = (float) (Math.min(width, height) / 2 * 0.85); 112 | mDrawRectf.set(-radius, -radius, radius, radius); 113 | 114 | canvas.drawArc(mDrawRectf, 0, 120, false, paint1); 115 | canvas.drawArc(mDrawRectf, 120, 120, false, paint2); 116 | canvas.drawArc(mDrawRectf, 240, 120, false, paint3); 117 | 118 | 119 | } 120 | 121 | } 122 | ``` 123 | 124 | so easy~依然是开头所说的,3只笔,3个角度,完事(极限一点,一支笔也可以完成【静态的情况下】)。 125 | 126 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/3.jpg) 127 | 128 | 然而,如果我们允许外部配置更多的甜甜圈的话,那岂不是要修改这个类?如果有一个方法能够提供给我动态增删就好了。 129 | 130 | 说做就做,我们看一下目前甜甜圈需要的元素: 131 | 132 | * 颜色 133 | * 描述 134 | * 数据(计算比例用) 135 | 136 | 也就是说,如果我们跟用户约定一个方式,约定好由用户提供这些元素,我们仅负责渲染的话,那么我们就可以实现动态增删的需求了。 137 | 138 | 那么,该如何约定呢? 139 | 140 | 假如我们要求使用一个类,也就是用户必须传给我们这个类,那么对于用户来说,是一个麻烦的使用,因为我们想绘制在图标上的数据往往都是服务器请求回来的,如果想画出甜甜圈,还得做多一步转换,这无疑是增大了复杂度,也就不符合我们简单好用的期望了。 141 | 142 | 考虑到这个,我采取了接口的方式。接口约束要求返回以上三个元素,这样做的好处是不用破坏用户原来的数据,他甚至可以用原来的数据bean来实现甜甜圈的需求,另一个好处是我可以很方便的进行扩展。 143 | 144 | 事实上,这一点也得到了使用者的认可(ps,在国外本库被评为2018年初值得关注的25个库之一,因此国外issue提出的比较多,邮件联系的也是主要是外国友人): 145 | 146 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/4.png) 147 | 148 | 根据以上条件,我们初步定义出一个接口:IPieInfo 149 | 150 | ```java 151 | public interface IPieInfo { 152 | 153 | double getValue(); 154 | 155 | @ColorInt 156 | int getColor(); 157 | 158 | String getDesc(); 159 | } 160 | ``` 161 | 162 | 在这之后,我们计算都可以依靠接口获取: 163 | 164 | * 获取颜色决定本段甜甜圈的取色 165 | * 获取数值来计算本段甜甜圈的 166 | * 获取描述来绘制甜甜圈的描述 167 | 168 | 这样做可以不限制用户的数据类型,只需要实现我们的接口并对各个方法进行返回即可。 169 | 170 | 在绘制的时候,我们把用户传入的数据存到一个List中,在存入的时候,根据获取的value进行计算,得到其开始角度和结束角度,并把数据包装在一个类里面供控件内部使用。而这一切,对外部来说都是不透明的,外部使用仅仅关注的是配置,而不是计算。 171 | 172 | ### 点击 173 | 174 | 甜甜圈的点击是一个比较麻烦的点,主要原因如下: 175 | 176 | * 甜甜圈支持起始角度设置,而对起始角度并没有做要求,也就是传入-3600°也是可以的 177 | * 点击的时候需要精确判定点击的区域在哪个甜甜圈里 178 | * 甜甜圈被点击后的动作,以及上一次点击的甜甜圈动画和本次点击的甜甜圈动画需要切换(一个还原一个上浮) 179 | 180 | 首先看看第一个问题,我们的甜甜圈虽然可以设置无限角度,但实际上其实归根结底可以归到0 ~ 360°之间,即便传入一个很大的值,其实也是一定倍数 * 360 + 偏移量而已,所以针对任意角度,我们需要将其收束到0 ~ 360°之间: 181 | 182 | ```java 183 | public class DegreeUtil { 184 | 185 | public static float limitDegreeInTo360(double inputAngle) { 186 | float result; 187 | double tInputAngle = inputAngle - (int) inputAngle;//取小数 188 | result = (float) ((int) inputAngle % 360.0f + tInputAngle); 189 | return result < 0 ? 360.0f + result : result; 190 | } 191 | } 192 | ``` 193 | 194 | 在点击触发的时候,我们先判断点击的位置是否在甜甜圈(或者饼图)内,判断的方法也很简单,就是初中的技巧计算两点之间的直线距离。 195 | 196 | 我们获取触摸点的x,y,计算其到中心的距离,假如当前是甜甜圈模式(环形饼图),则需要甜甜圈内径≤距离≤甜甜圈外径则判定在甜甜圈内。 197 | 198 | ```java 199 | PieInfoWrapper pointToPieInfoWrapper(float x, float y) { 200 | final boolean isStrokeMode = mConfig.isStrokeMode(); 201 | final float strokeWidth = mConfig.getStrokeWidth(); 202 | //外圆半径 203 | final float exCircleRadius = isStrokeMode ? pieRadius + strokeWidth / 2 : pieRadius; 204 | //内圆半径 205 | final float innerCircleRadius = isStrokeMode ? pieRadius - strokeWidth / 2 : 0; 206 | //点击位置到圆心的直线距离(没开根) 207 | final double touchDistancePow = Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2); 208 | //内圆半径<=直线距离<=外圆半径 209 | final boolean isTouchInRing = touchDistancePow >= expandClickRange + Math.pow(innerCircleRadius, 2) 210 | && touchDistancePow <= expandClickRange + Math.pow(exCircleRadius, 2); 211 | if (!isTouchInRing) return null; 212 | return findWrapper(x, y); 213 | } 214 | ``` 215 | 216 | 计算完距离后,我们需要计算角度,我们通过角度来获取我们当前点击的是哪一段的甜甜圈。 217 | 218 | 目前我们已知点击的xy,以及中心点,这时候我们可以用atan2方法反计算出角度 219 | ```java 220 | //得到角度 221 | double touchAngle = Math.toDegrees(Math.atan2(y - centerY, x - centerX)); 222 | ``` 223 | 224 | 这里计算出来的是-180° ~ 180°范围内的值,即以x正半轴为起始,逆时针(1、2象限)则是-180° ~ 0,顺时针(3、4象限)是0 ~ 180°。而我们的甜甜圈在开始的时候也说过,是无限角度的,当然,我们处理到0 ~ 360°,然而即便收束了起来,还是与我们计算出来的-180° ~ 180°对不上,因此我们对计算出来的角度需要做一下处理。 225 | 226 | 在这里我选择当点击的角度小于0的时候,加上360°。这里可能会有小伙伴问我为什么不是加上180°,而是加360°。 227 | 228 | 这个问题很简单,因为我们的甜甜圈在转换为0 ~ 360°之后,在1、2象限表现的是180° ~ 360°的范围,而我们点击的角度在1、2象限是-180° ~ 0,如果加上180,则是0 ~ 180°,还是无法满足我们的甜甜圈判断,因此在数值小于0的情况下,我们加上360°。 229 | 230 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/8.png) 231 | 232 | 接着我们根据角度寻找每一段甜甜圈里匹配的角度进行查询,直到找到为止。 233 | 234 | ```java 235 | PieInfoWrapper findWrapper(float x, float y) { 236 | //得到角度 237 | double touchAngle = Math.toDegrees(Math.atan2(y - centerY, x - centerX)); 238 | if (touchAngle < 0) { 239 | touchAngle += 360.0f; 240 | } 241 | if (lastTouchWrapper != null && lastTouchWrapper.containsTouch((float) touchAngle)) { 242 | return lastTouchWrapper; 243 | } 244 | PLog.i("touch角度 = " + touchAngle); 245 | for (PieInfoWrapper wrapper : mDataWrappers) { 246 | if (wrapper.containsTouch((float) touchAngle)) { 247 | lastTouchWrapper = wrapper; 248 | return wrapper; 249 | } 250 | } 251 | return null; 252 | } 253 | ``` 254 | 255 | ```java 256 | boolean containsTouch(float angle) { 257 | //所有点击的角度都需要收归到0~360的范围,兼容任意角度 258 | final float tAngle = DegreeUtil.limitDegreeInTo360(angle); 259 | float tStart = DegreeUtil.limitDegreeInTo360(fromAngle); 260 | float tEnd = DegreeUtil.limitDegreeInTo360(toAngle); 261 | PLog.d("containsTouch >> tStart: " + tStart + " tEnd: " + tEnd + " tAngle: " + tAngle); 262 | boolean result; 263 | if (tEnd < tStart) { 264 | if (tAngle > 180) { 265 | //已经过界 266 | result = tAngle >= tStart && (360 - tAngle) <= sweepAngle; 267 | } else { 268 | result = tAngle + 360 >= tStart && tAngle <= tEnd; 269 | } 270 | } else { 271 | result = tAngle >= tStart && tAngle <= tEnd; 272 | } 273 | if (result) { 274 | PLog.i("find touch point >> " + toString()); 275 | } 276 | return result; 277 | } 278 | ``` 279 | 280 | 在查找的时候我们还需要注意转换为0 ~ 360的情况中有一种特殊情况,就是某段甜甜圈跨越了0和360的界限。比如说图中的情况: 281 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/11.png) 282 | 283 | 284 | 其他的部分比如点击的动画实现等,则是由Animator计算并不断重绘。这里就不再详细说明。 285 | 286 | 287 | ### 文字 288 | 289 | 文字的绘制相对简单,我们需要确定的是文字的位置就可以了。 290 | 291 | 从效果图上我们知道,文字绘制有个引导线,而文字要么在引导线上,要么在引导线下,要么上下都有。 292 | 293 | 为了扩展,此处我粗暴的给出了四种文字属性: 294 | 295 | * 文字都在引导线上 296 | * 文字都在引导线下 297 | * 文字在1、2象限在引导线上,在3、4象限处于引导线下 298 | * 文字与引导线对齐 299 | 300 | ![](https://github.com/razerdp/Article/blob/master/pics/一起撸个甜甜圈/16.jpg) 301 | 302 | 计算文字的位置首当其冲我们得确认文字所处象限,然而我们仅仅知道的条件只有这段甜甜圈的起始、结束角度、甜甜圈半径,因此我们需要把角度换算为距离。 303 | 304 | 那我们需要怎么做呢?实际上这也是三角函数的简单运用,根据效果图,文字指引线的起始点是在甜甜圈的中间,因此我们可以根据甜甜圈的中心点到某段甜甜圈的中间连线作为三角形的斜边,根据三角函数sin/cos求出x,y的坐标即可。 305 | 306 | ```java 307 | private void drawText(Canvas canvas, PieInfoWrapper wrapper) { 308 | if (wrapper == null) return; 309 | 310 | //根据touch扩大量修正指示线和描述文字的位置 311 | float fixPos = (wrapper.equals(mTouchHelper.floatingWrapper) ? getFixTextPos(wrapper) : 0) + (wrapper.equals(mTouchHelper.lastFloatWrapper) ? getFixTextPos(wrapper) : 0); 312 | 313 | final float pointMargins = fixPos 314 | + pieRadius 315 | + mConfig.getGuideLineMarginStart() 316 | + (mConfig.isStrokeMode() ? mConfig.getStrokeWidth() / 2 : 0); 317 | float cx = (float) (pointMargins * Math.cos(Math.toRadians(wrapper.getMiddleAngle()))); 318 | float cy = (float) (pointMargins * Math.sin(Math.toRadians(wrapper.getMiddleAngle()))); 319 | 320 | //略 321 | 322 | } 323 | ``` 324 | 325 | 求出了文字指引线的起始点,我们就清楚该文字属于哪个象限了,简单的判断x,y即可 326 | 327 | ```java 328 | private LineDirection calculateLineGravity(float startX, float startY) { 329 | if (startX > 0) { 330 | //在右边 331 | return startY > 0 ? LineDirection.BOTTOM_RIGHT : LineDirection.TOP_RIGHT; 332 | } else if (startX < 0) { 333 | //在左边 334 | return startY > 0 ? LineDirection.BOTTOM_LEFT : LineDirection.TOP_LEFT; 335 | } else if (startY == 0) { 336 | //刚好中间 337 | return startX > 0 ? LineDirection.CENTER_RIGHT : LineDirection.CENTER_LEFT; 338 | } 339 | return LineDirection.TOP_RIGHT; 340 | } 341 | ``` 342 | 343 | 最后关于文字引导线的绘制,我们可以简单的PathMeasure使Path动态生长。 344 | 345 | ### 总结 346 | 347 | 对于甜甜圈,这个项目的主要难点在本篇已经说出,这个工程说复杂其实也不是很复杂,但说简单我个人认为也不能说分分钟完事,其实还是有挺多细节需要琢磨的。 348 | 349 | 诚然,这个库还是很有进步空间,比如issue里面所说的图例以及数据过多时的文字重叠等等(说起来,数据过多本就不适合饼图啊。。。。)。 350 | 351 | 但只要我还收到issue,我就不会放弃更新,一直迭代下去~ 352 | 353 | 更多的话就不多说了,欢迎大家阅览项目:https://github.com/razerdp/AnimatedPieView 354 | 355 | >同时也顺便推荐我的另一个项目:BasePopup:https://github.com/razerdp/BasePopup 356 | 357 | 欢迎交流哈-V- 358 | -------------------------------------------------------------------------------- /亲,还在为PopupWindow烦恼吗.md: -------------------------------------------------------------------------------- 1 | ### 亲,还在为PopupWindow烦恼吗 2 | 3 | *** 4 | 5 | >ps:预览图放到了文章最后 6 | 7 | 这篇文章其实想写很久了,然而一直以来总觉得BasePopup达不到自己的期望,所以也没有怎么去传播推荐,也因此一直都没有去写文章,直到最近狠下心重构2.0版本,并且完善了wiki等api文档后,才稍微满意了点,因此才开始着手写下这篇文章。 8 | 9 | 仓库地址:https://github.com/razerdp/BasePopup 10 | 11 | 相比于star,我更在乎您的issue~。 12 | 13 | *** 14 | 15 | ### 现状 16 | 17 | 跟不少产品经理、设计撕逼过的Android猿们应该都知道一件事:**没有什么交互,是不能用弹窗解决的,如果有,就弹多一个。** 18 | 19 | 诚然,如何榨干有限的屏幕空间同时又保持优雅的界面,是每个交互设计都要去思考的事情。 20 | 21 | 这时候,他们往往会选择一个神器:**弹窗**。 22 | 23 | 无论是从底部弹出还是从中间弹出,亦或是上往下弹右往左弹,甚至是弹出的时候带有动画,变暗,模糊等交互,在弹窗上的花样越来越多,而哭的,也往往是我们程序员。。。 24 | 25 | 在Android中,为了应付弹窗,我们可以选的东西其实挺多的: 26 | 27 | - Dialog 28 | - BottomSheetDialog 29 | - DialogFragment 30 | - PopupWindow 31 | - WindowManager直接怼入一个View 32 | - Dialog样式的Activity 33 | - 等等等等.... 34 | 35 | 很多时候,我们都会选择Dialog而不选择PopupWindow,至于原因,很简单。。。PopupWindow好多坑!!! 36 | 37 | 38 | ### PopupWindow的优缺点 39 | 40 | 先说优点,相比于Dialog,PopupWindow的位置比较随意,可以在任意位置显示,而Dialog相对固定,其次就是背景变暗的效果,PopupWindow可以轻松的定制背景,无需复杂的黑科技。 41 | 42 | 而缺点,也有很多,这也是为什么大家更偏向于Dialog的原因,以下列举几条我认为最显著的缺点: 43 | - 创建复杂,与Dialog相比,每次都得写模板化的那几条初始化,很烦 44 | - 点击事件的蛋疼,要么无法响应backpress,要么点击外部不消失(**各个系统版本间的background问题**) 45 | - 系统版本的差异,每一次新系统的发布,都可以发现PopupWindow也悄悄的有所改动,而且更坑的是,往往在修复了旧的bug后,又引入了新的问题(**比如7.0高度match_parent时与以前显示不同的问题**) 46 | - PopupWindow内无法使用粘贴弹窗(**这个是固有问题,因为粘贴那个功能弹窗也是PopupWindow,而PopupWindow内的View是无法拿到windowToken的**) 47 | - 位置定位繁琐 48 | 49 | 为此,BasePopup库就诞生了。 50 | 51 | ### BasePopup解决方案 52 | 53 | 从1.0发布到现在2.1.1(准备发布2.1.2),为了开发BasePopup,走过的坑和读过的PopupWindow源码可以说是非常多了,当然,到现在为止,都还有一些坑没填,但BasePopup已经可以适配大多数情况了。 54 | 55 | 虽然这篇文章主要是推荐BasePopup,但更多的,是为了跟大家分享一下我的解决Idea,一直以来都是我一个人维护这个库,也没有多少人跟我交流其中的实现要点,在这里借这篇文章分享,同时也希望能得到更多人的建议或批评。 56 | 57 | #### 创建复杂 58 | 59 | 首先我们看看普通的PopupWindow写法: 60 | 61 | ```java 62 | //ps,以下三句其实都可以合并成一句在构造方法里,然而为了防止内容过长,这里分开写 63 | PopupWindow popupWindow = new PopupWindow(this); 64 | popupWindow.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); 65 | popupWindow.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); 66 | popupWindow.setContentView(LayoutInflater.from(this).inflate(R.layout.layout_popupwindow, null)); 67 | popupWindow.setBackgroundDrawable(new ColorDrawable(0x00000000)); 68 | popupWindow.setOutsideTouchable(false); 69 | popupWindow.setFocusable(true); 70 | ``` 71 | 72 | 虽然上面打了个注释解释道上面有几行是可以合并到同一个构造方法里解决,但PopupWindow有着5个以上的构造方法,即便有着IDE的自动提示,相信面对一大堆的构造方法依然是很头疼吧。 73 | 74 | 在BasePopup里,我们只需要继承**BasePopupWindow**并覆写`onCreateContentView`方法返回您的contentView即可,对于外部来说,只需要写两行甚至是一行代码就完成了。 75 | 76 | ```java 77 | new DemoPopup(getContext()).showPopupWindow(); 78 | ``` 79 | 也许你会说,这不更蛋疼了么,为了一个PopupWindow,我不得不写多一个类。 80 | 81 | 这个问题就如MVP一样,为了更好地结构而不得不创建多一些类。。。 82 | 83 | BasePopup之所以 写成一个抽象类,除了更大程度的开放给开发者,更多的是让开发者更好地把功能内聚到PopupWindow中,而不是去解决PopupWindow的各种蛋疼的坑。 84 | 85 | 当然,为了满足一些简单的PopupWindow实现而不希望又新建一个类,我们也提供了懒懒的方法支持链式使用: 86 | 87 | ```java 88 | QuickPopupBuilder.with(getContext()) 89 | .contentView(R.layout.popup_normal) 90 | .config(new QuickPopupConfig() 91 | .gravity(Gravity.RIGHT | Gravity.CENTER_VERTICAL) 92 | .withClick(R.id.tx_1, new View.OnClickListener() { 93 | @Override 94 | public void onClick(View v) { 95 | Toast.makeText(getContext(), "clicked", Toast.LENGTH_LONG).show(); 96 | } 97 | })) 98 | .show(); 99 | ``` 100 | 101 | BasePopup是一个抽象类,具体实现交由子类(也就是开发者完成),同时也提供拦截器供开发者干预内部逻辑,最大化的开放自定义权限。 102 | 103 | 也许有更好的方法或设计模式,比如适配器等,这里就不细说了。 104 | 105 | 相比于封装相信您更关心其他的实现。 106 | 107 | *** 108 | 109 | #### 事件消费 110 | 111 | PopupWindow的事件一直都是让人头疼的事情,在6.0之前如果不设置background,那么是无法响应外部点击事件,而在6.0之后又修复了这一问题。 112 | 113 | 导致这一事情发生的,其实是跟PopupWindow内部的实现机制有关。 114 | 115 | 当我们给PopupWindow设置一个contentView的时候,这一个contentView其实是被PopupWindow内部的DecorView包裹住,而事件的响应则是由这个DecorView来分发。 116 | 117 | 在6.0之前,`PopupWindow#preparePopup()`源码如下: 118 | ```java 119 | private void preparePopup(WindowManager.LayoutParams p) { 120 | //忽略部分代码 121 | 122 | if (mBackground != null) { 123 | //忽略部分代码,当background不为空,才把contentView包裹进来 124 | PopupViewContainer popupViewContainer = new PopupViewContainer(mContext); 125 | PopupViewContainer.LayoutParams listParams = new PopupViewContainer.LayoutParams( 126 | ViewGroup.LayoutParams.MATCH_PARENT, height 127 | ); 128 | popupViewContainer.setBackground(mBackground); 129 | popupViewContainer.addView(mContentView, listParams); 130 | 131 | mPopupView = popupViewContainer; 132 | } else { 133 | mPopupView = mContentView; 134 | } 135 | //忽略后面代码 136 | } 137 | ``` 138 | 139 | 而从6.0开始,preparePopup源码如下: 140 | ```java 141 | private void preparePopup(WindowManager.LayoutParams p) { 142 | //忽略部分代码 143 | if (mBackground != null) { 144 | mBackgroundView = createBackgroundView(mContentView); 145 | mBackgroundView.setBackground(mBackground); 146 | } else { 147 | mBackgroundView = mContentView; 148 | } 149 | //把contentView包裹到DecorView 150 | mDecorView = createDecorView(mBackgroundView); 151 | mDecorView.setIsRootNamespace(true); 152 | 153 | //忽略后面代码 154 | } 155 | ``` 156 | 157 | 对于PopupWindow的事件,是在内部DecorView的`dispatchKeyEvent`和`onTouchEvent`方法里处理的,这里就不贴源码了。 158 | 159 | 由于`dispatchKeyEvent`我们无法通过设置事件监听去拦截,而PopupWindow的`DecorView`又无法获取,看起来事件的分发进入了一个死胡同,然而通过细读源码,我们找到了一个突破口:**WindowManager**。 160 | 161 | #### proxy WindowManager 162 | 163 | PopupWindow没有创建一个新的Window,它通过WindowManager添加一个新的View,其Type为`TYPE_APPLICATION_PANEL`,因此PopupWindow需要windowToken来作为依附。 164 | 165 | 在PopupWindow中,我们的contentView被包裹进DecorView,而DecorView则是通过WindowManager添加到界面中。 166 | 167 | 由于事件分发是在DecorView中,且没有监听器去拦截,**因此我们需要把这个DecorView再包多一层我们自定义的控件,然后添加到Window中**,这样一来,DecorView就成了我们的子类,对于事件的分发(甚至是measure/layout),我们就有了绝对的控制权,BasePopup正是这样做的。 168 | 169 | 然而,以上的步骤有个前提,就是如何代理掉WindowManager。(相当于寻找hook点) 170 | 171 | 在PopupWindow中,我们通过读源码可以获知,PopupWindow中的WindowManager是在两个地方被初始化: 172 | 173 | - 构造方法里 174 | - `setContentView()` 175 | 176 | 因此,我们也从这两个地方入手,继承PopupWindow并覆写以上两个方法,在里面通过反射来获取WindowManager并把它包裹到我们的`WindowManagerProxy`里面,然后再把我们的WindowManagerProxy设置给PopupWindow,这样就成功的偷天换日(代理)。 177 | 178 | ```java 179 | abstract class BasePopupWindowProxy extends PopupWindow { 180 | private static final String TAG = "BasePopupWindowProxy"; 181 | 182 | private BasePopupHelper mHelper; 183 | private WindowManagerProxy mWindowManagerProxy; 184 | 185 | //构造方法皆有调用init(),此处忽略其他构造方法 186 | 187 | public BasePopupWindowProxy(View contentView, int width, int height, boolean focusable, BasePopupHelper helper) { 188 | super(contentView, width, height, focusable); 189 | this.mHelper = helper; 190 | init(contentView.getContext()); 191 | } 192 | 193 | void bindPopupHelper(BasePopupHelper mHelper) { 194 | if (mWindowManagerProxy == null) { 195 | tryToProxyWindowManagerMethod(this); 196 | } 197 | mWindowManagerProxy.bindPopupHelper(mHelper); 198 | } 199 | 200 | private void init(Context context) { 201 | setFocusable(true); 202 | setOutsideTouchable(true); 203 | setBackgroundDrawable(new ColorDrawable()); 204 | tryToProxyWindowManagerMethod(this); 205 | } 206 | 207 | @Override 208 | public void setContentView(View contentView) { 209 | super.setContentView(contentView); 210 | tryToProxyWindowManagerMethod(this); 211 | } 212 | 213 | 214 | 215 | /** 216 | * 尝试代理掉windowmanager 217 | * 218 | * @param popupWindow 219 | */ 220 | private void tryToProxyWindowManagerMethod(PopupWindow popupWindow) { 221 | if (mHelper == null || mWindowManagerProxy != null) return; 222 | PopupLogUtil.trace("cur api >> " + Build.VERSION.SDK_INT); 223 | troToProxyWindowManagerMethodBeforeP(popupWindow); 224 | } 225 | 226 | // android p 之后的代理,需要使用黑科技 227 | private void troToProxyWindowManagerMethodOverP(PopupWindow popupWindow) { 228 | try { 229 | WindowManager windowManager = PopupReflectionHelper.getInstance().getPopupWindowManager(popupWindow); 230 | if (windowManager == null) return; 231 | mWindowManagerProxy = new WindowManagerProxy(windowManager); 232 | PopupReflectionHelper.getInstance().setPopupWindowManager(popupWindow, mWindowManagerProxy); 233 | } catch (Exception e) { 234 | e.printStackTrace(); 235 | } 236 | } 237 | 238 | // android p 之前的代理,普通反射即可 239 | private void troToProxyWindowManagerMethodBeforeP(PopupWindow popupWindow) { 240 | try { 241 | Field fieldWindowManager = PopupWindow.class.getDeclaredField("mWindowManager"); 242 | fieldWindowManager.setAccessible(true); 243 | final WindowManager windowManager = (WindowManager) fieldWindowManager.get(popupWindow); 244 | if (windowManager == null) return; 245 | mWindowManagerProxy = new WindowManagerProxy(windowManager); 246 | fieldWindowManager.set(popupWindow, mWindowManagerProxy); 247 | PopupLogUtil.trace(LogTag.i, TAG, "尝试代理WindowManager成功"); 248 | } catch (NoSuchFieldException e) { 249 | if (Build.VERSION.SDK_INT >= 27) { 250 | troToProxyWindowManagerMethodOverP(popupWindow); 251 | } else { 252 | e.printStackTrace(); 253 | } 254 | } catch (Exception e) { 255 | e.printStackTrace(); 256 | } 257 | } 258 | 259 | } 260 | ``` 261 | 262 | 说到反射,想必这里就有人觉得会不会存在性能问题,说实话,我当初也有这个顾虑,但实际上,从ART以来,反射的性能影响其实已经降低了很多,同时我们这里并非频繁的反射,所以在这一点上我认为可以忽略。 263 | 264 | >另外反射获取WindowManager在Android P或以上并非在白名单中,因此BasePopup在这里通过`UnSafe`来绕过Api调用的控制,该方法参考[**android_p_no_sdkapi_support**](https://github.com/Guolei1130/android_p_no_sdkapi_support),文章里总结了几种方法,本库采取最后一种,具体的这里就不细说了。 265 | 266 | 267 | #### 系统版本的差异及其他问题 268 | 269 | ##### 位置控制 270 | 271 | 系统版本导致的位置问题很是让人头疼,在之前我通过一个类来适配api24之前,api24,以及api24之后,后来发现越写越多,因此产生了一个大胆的想法: 272 | 273 | **PopupWindow的位置,我们自己来决定** 274 | 275 | 由于上面的代理,我们对PopupWindow的DecorView有着绝对的控制,所以由于系统版本导致PopupWindow显示的问题也很好解决。 276 | 277 | 对于PopupWindow的位置,因为DecorView是我们的自定义控件的子控件,因此在BasePopup中采取的方式是完全重写`onLayout()`。 278 | 279 | 我们的自定义控件是铺满整个屏幕的,因此我们针对DecorView进行layout,在视觉上的效果就是这个PopupWindow显示在了指定的位置上(**背景透明,而contentView是用户指定的xml,一般有颜色**),但实际上PopupWindow是铺满整个屏幕的。 280 | 281 | >**(当然,对于普通的使用,也就PopupWindow不铺满整个屏幕也有适配)** 282 | 283 | 以下是layout的部分代码: 284 | ```java 285 | private void layoutWithIntercept(int l, int t, int r, int b) { 286 | final int childCount = getChildCount(); 287 | for (int i = 0; i < childCount; i++) { 288 | View child = getChildAt(i); 289 | if (child.getVisibility() == GONE) continue; 290 | int width = child.getMeasuredWidth(); 291 | int height = child.getMeasuredHeight(); 292 | 293 | int gravity = mHelper.getPopupGravity(); 294 | 295 | int childLeft = child.getLeft(); 296 | int childTop = child.getTop(); 297 | 298 | int offsetX = mHelper.getOffsetX(); 299 | int offsetY = mHelper.getOffsetY(); 300 | 301 | boolean delayLayoutMask = mHelper.isAlignBackground(); 302 | 303 | boolean keepClipScreenTop = false; 304 | 305 | if (child == mMaskLayout) { 306 | child.layout(childLeft, childTop, childLeft + width, childTop + height); 307 | } else { 308 | boolean isRelativeToAnchor = mHelper.isShowAsDropDown(); 309 | int anchorCenterX = mHelper.getAnchorX() + (mHelper.getAnchorViewWidth() >> 1); 310 | int anchorCenterY = mHelper.getAnchorY() + (mHelper.getAnchorHeight() >> 1); 311 | //不跟anchorView联系的情况下,gravity意味着在整个view中的方位 312 | //如果跟anchorView联系,gravity意味着以anchorView为中心的方位 313 | switch (gravity & Gravity.HORIZONTAL_GRAVITY_MASK) { 314 | case Gravity.LEFT: 315 | case Gravity.START: 316 | if (isRelativeToAnchor) { 317 | childLeft = mHelper.getAnchorX() - width + childLeftMargin; 318 | } else { 319 | childLeft += childLeftMargin; 320 | } 321 | break; 322 | case Gravity.RIGHT: 323 | case Gravity.END: 324 | if (isRelativeToAnchor) { 325 | childLeft = mHelper.getAnchorX() + mHelper.getAnchorViewWidth() + childLeftMargin; 326 | } else { 327 | childLeft = getMeasuredWidth() - width - childRightMargin; 328 | } 329 | break; 330 | case Gravity.CENTER_HORIZONTAL: 331 | if (isRelativeToAnchor) { 332 | childLeft = mHelper.getAnchorX(); 333 | offsetX += anchorCenterX - (childLeft + (width >> 1)); 334 | } else { 335 | childLeft = ((r - l - width) >> 1) + childLeftMargin - childRightMargin; 336 | } 337 | break; 338 | default: 339 | if (isRelativeToAnchor) { 340 | childLeft = mHelper.getAnchorX() + childLeftMargin; 341 | } 342 | break; 343 | } 344 | 345 | switch (gravity & Gravity.VERTICAL_GRAVITY_MASK) { 346 | case Gravity.TOP: 347 | if (isRelativeToAnchor) { 348 | childTop = mHelper.getAnchorY() - height + childTopMargin; 349 | } else { 350 | childTop += childTopMargin; 351 | } 352 | break; 353 | case Gravity.BOTTOM: 354 | if (isRelativeToAnchor) { 355 | keepClipScreenTop = true; 356 | childTop = mHelper.getAnchorY() + mHelper.getAnchorHeight() + childTopMargin; 357 | } else { 358 | childTop = b - t - height - childBottomMargin; 359 | } 360 | break; 361 | case Gravity.CENTER_VERTICAL: 362 | if (isRelativeToAnchor) { 363 | childTop = mHelper.getAnchorY() + mHelper.getAnchorHeight(); 364 | offsetY += anchorCenterY - (childTop + (height >> 1)); 365 | } else { 366 | childTop = ((b - t - height) >> 1) + childTopMargin - childBottomMargin; 367 | } 368 | break; 369 | default: 370 | if (isRelativeToAnchor) { 371 | keepClipScreenTop = true; 372 | childTop = mHelper.getAnchorY() + mHelper.getAnchorHeight() + childTopMargin; 373 | } else { 374 | childTop += childTopMargin; 375 | } 376 | break; 377 | } 378 | 379 | int left = childLeft + offsetX; 380 | int top = childTop + offsetY + (mHelper.isFullScreen() ? 0 : -getStatusBarHeight()); 381 | int right = left + width; 382 | int bottom = top + height; 383 | 384 | //针对clipToScreen和autoLocated的情况,这里因篇幅限制忽略 385 | } 386 | child.layout(left, top, right, bottom); 387 | if (delayLayoutMask) { 388 | mMaskLayout.handleAlignBackground(left, top, right, bottom); 389 | } 390 | } 391 | 392 | } 393 | } 394 | ``` 395 | 396 | 对于layout,我们只需要区分PopupWindow是否跟anchorView关联,然后根据Gravity和Offset进行位置的计算。 397 | 398 | 这些操作对于经常自定义控件的同学来说简直就是拈手即来。 399 | 400 | 而对于平时的PopupWindow用法,即PopupWindow不铺满整个屏幕,在BasePopup中则是跟普通用法一样计算offset。 401 | 402 | ```java 403 | private void onCalculateOffsetAdjust(Point offset, boolean positionMode, boolean relativeToAnchor) { 404 | int leftMargin = 0; 405 | int topMargin = 0; 406 | int rightMargin = 0; 407 | int bottomMargin = 0; 408 | if (mHelper.getParaseFromXmlParams() != null) { 409 | leftMargin = mHelper.getParaseFromXmlParams().leftMargin; 410 | topMargin = mHelper.getParaseFromXmlParams().topMargin; 411 | rightMargin = mHelper.getParaseFromXmlParams().rightMargin; 412 | bottomMargin = mHelper.getParaseFromXmlParams().bottomMargin; 413 | } 414 | //由于showAsDropDown系统已经帮我们定位在view的下方,因此这里的offset我们仅需要做微量偏移 415 | switch (getPopupGravity() & Gravity.HORIZONTAL_GRAVITY_MASK) { 416 | case Gravity.LEFT: 417 | case Gravity.START: 418 | if (relativeToAnchor) { 419 | offset.x += -getWidth() + leftMargin; 420 | } else { 421 | offset.x += leftMargin; 422 | } 423 | break; 424 | case Gravity.RIGHT: 425 | case Gravity.END: 426 | if (relativeToAnchor) { 427 | offset.x += mHelper.getAnchorViewWidth() + leftMargin; 428 | } else { 429 | offset.x += getScreenWidth() - getWidth() - rightMargin; 430 | } 431 | break; 432 | case Gravity.CENTER_HORIZONTAL: 433 | if (relativeToAnchor) { 434 | offset.x += (mHelper.getAnchorViewWidth() - getWidth()) >> 1; 435 | } else { 436 | offset.x += ((getScreenWidth() - getWidth()) >> 1) + leftMargin - rightMargin; 437 | } 438 | break; 439 | default: 440 | if (!relativeToAnchor) { 441 | offset.x += leftMargin; 442 | } 443 | break; 444 | } 445 | 446 | switch (getPopupGravity() & Gravity.VERTICAL_GRAVITY_MASK) { 447 | case Gravity.TOP: 448 | if (relativeToAnchor) { 449 | offset.y += -(mHelper.getAnchorHeight() + getHeight()) + topMargin; 450 | } else { 451 | offset.y += topMargin; 452 | } 453 | break; 454 | case Gravity.BOTTOM: 455 | //系统默认就在下面. 456 | if (!relativeToAnchor) { 457 | offset.y += getScreenHeight() - getHeight() - bottomMargin; 458 | } 459 | break; 460 | case Gravity.CENTER_VERTICAL: 461 | if (relativeToAnchor) { 462 | offset.y += -((getHeight() + mHelper.getAnchorHeight()) >> 1); 463 | } else { 464 | offset.y += ((getScreenHeight() - getHeight()) >> 1) + topMargin - bottomMargin; 465 | } 466 | break; 467 | default: 468 | if (!relativeToAnchor) { 469 | offset.y += topMargin; 470 | } 471 | break; 472 | } 473 | ``` 474 | 475 | 正因为位置有我们来控制,所以不仅仅在所有版本中统一了位置的计算方式,而且更重要的是,PopupWindow的`Gravity`这一个属性被充分使用,再也不用去计算心塞的偏移量了。 476 | 477 | 举个例子,比如我们要显示在某个 view的右边,同时自己跟他垂直对齐。 478 | 479 | 在系统的PopupWindow中,你可能要这么写: 480 | 481 | ```java 482 | 483 | //前面忽略创建方法 484 | popup.showAsDropDown(v,v.getWidth(),-(v.getHeight()+popup.getHeight())>>1) 485 | ``` 486 | 487 | 上面的代码还是比较简单的,popup默认显示在anchorView的下方,此处需要计算偏移量,使popup可以偏移到view的右方,但是有个值得关注的是popup在显示之前是获取不到正确的contentView的宽高的。 488 | 489 | 490 | 而在BasePopup中,你要写的,仅仅是这样: 491 | 492 | ```java 493 | 494 | //前面忽略创建方法 495 | popup.setPopupGravity(Gravity.RIGHT|Gravity.CENTER_VERTICAL); 496 | popup.showPopupWindow(anchorView); 497 | ``` 498 | 499 | 在BasePopup中,因为layout由我们接管,因此在onLayout中我们其实是知道contentView的宽高,因此根据上面的代码,我们直接通过Gravity来计算出Popup的正确位置即可。 500 | 501 | **关于Gravity的Demo**: 502 | 503 | 504 | 505 | 506 | 507 | ##### 背景模糊 508 | 509 | 同时我们可以针对这个自定义的ViewGroup默认添加背景,在BasePopup中,背景添加了一个ImageView和一个View,分别处理模糊和背景颜色。 510 | 511 | 其中背景的模糊采取的是RenderScript,对于不支持的情况则采取fastBlur,由于模糊基本上大同小异,在这里就不贴代码了。 512 | 513 | 514 | #### 其他问题 515 | 516 | 到目前位置,BasePopup满足多数的PopupWindow使用,但仍然有不足,比如没有支持PopupWindow的update()方法,因为我们多数时候PopupWindow都是展示用,而且基本上都是展示一次后就消掉。 517 | 518 | 但不排除有PopupWindow跟随某个View而更新自己的位置这一需求,因此在接下来的维护里,这个问题将会纳入到之后的工作中。 519 | 520 | 最后感谢提issue的小伙伴们,你们的每一个issue我都认真的看且有空就去清掉。 521 | 522 | 最后的最后,希望本文能对看到这篇文章的你有些帮助~ 523 | 524 | thanks 525 | 526 | 仓库地址:https://github.com/razerdp/BasePopup 527 | 528 | *** 529 | 530 | **18/12/19:candy版本更新到2.1.3-alpha,已经支持update~感谢支持** 531 | 532 | 预览图: 533 | 534 | | **anchorView绑定** | **不同方向弹出** | 535 | | - | - | 536 | | | | 537 | | **任意位置显示** | **参考anchorView更新** | 538 | | | | 539 | | **从下方弹出并模糊背景** | **朋友圈评论弹窗** | 540 | | | | 541 | 542 | 543 | 544 | 545 | 546 | --------------------------------------------------------------------------------