├── .gitignore ├── .gitignore.bak ├── .idea ├── codeStyles │ └── Project.xml ├── gradle.xml ├── misc.xml ├── modules.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── hewking │ │ └── github │ │ └── customviewdemo │ │ └── ApplicationTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── hewking │ │ │ ├── MainActivity.java │ │ │ ├── ViewEx.kt │ │ │ └── widget │ │ │ ├── CircleImageView.kt │ │ │ ├── TanTanRippleView.kt │ │ │ └── TideRippleView.kt │ └── res │ │ ├── layout │ │ ├── activity_dbtest.xml │ │ ├── activity_main.xml │ │ ├── activity_retrofit.xml │ │ ├── content_main.xml │ │ └── retrofit.xml │ │ ├── menu │ │ └── menu_main.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── nice_girl.jpg │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── user.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ ├── values-1280x672 │ │ └── dimens.xml │ │ ├── values-1280x720 │ │ └── dimens.xml │ │ ├── values-1920x1080 │ │ └── dimens.xml │ │ ├── values-nodpi │ │ └── dimens.xml │ │ ├── values-v21 │ │ └── styles.xml │ │ ├── values-w820dp │ │ └── dimens.xml │ │ └── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── hewking │ └── github │ └── customviewdemo │ └── ExampleUnitTest.java ├── art └── 20190407_090017.gif ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | 10 | 11 | 12 | # Built application files 13 | *.apk 14 | *.ap_ 15 | 16 | # Files for the ART/Dalvik VM 17 | *.dex 18 | 19 | # Java class files 20 | *.class 21 | 22 | # Generated files 23 | bin/ 24 | gen/ 25 | out/ 26 | 27 | # Gradle files 28 | .gradle/ 29 | build/ 30 | 31 | # Local configuration file (sdk path, etc) 32 | local.properties 33 | 34 | # Proguard folder generated by Eclipse 35 | proguard/ 36 | 37 | # Log Files 38 | *.log 39 | 40 | # Android Studio Navigation editor temp files 41 | .navigation/ 42 | 43 | # Android Studio captures folder 44 | captures/ 45 | 46 | # Intellij 47 | *.iml 48 | .idea/workspace.xml 49 | .idea/libraries 50 | 51 | # Keystore files 52 | *.jks 53 | 54 | # External native build folder generated in Android Studio 2.2 and later 55 | .externalNativeBuild 56 | -------------------------------------------------------------------------------- /.gitignore.bak: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 hewking 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > 闲着也是闲着的时候,打开探探划一划,挺多男同胞会这样吧。这不,我也是这样,看到首页探探的效果还是挺吸引人的。之前仿照实现了一个,效果还差一点,正好今天没事完善一下,写下来,希望看到能有收获。 2 | 3 | ##### 实现的效果 4 | 首先看看实现后的效果,先不多说。当然跟探探的原版还是有差距的,没有在细节上面优化的更多。不过花时间调一调还是可以的,现在的效果可以看到,我在下面加了帧数的显示,在真机上显示还是很流畅的,模拟器上由于性能不行还是有点卡。 5 | 6 | ![实现效果](https://user-gold-cdn.xitu.io/2019/4/7/169f81ecb5cb9a18?w=480&h=845&f=gif&s=1986428) 7 | 8 | 9 | ##### 实现的分析 10 | 通过效果图可以看到,整体的实现可以分为以下四步: 11 | 1. 波纹涟漪的效果 12 | 2. 渐变扫描的效果和中间的镂空 13 | 3. 旋转 14 | 4. 点击头像的动画 15 | 16 | 把以上步骤分别加以实现,就可以做到了。具体实现方法也不止一种,我这里选择的实现还算是简单易懂,易于实现的。以下分解各个步骤,并对关键的细节详加解释。 17 | 18 | ##### 如何实现 19 | 因为有头像,并且涉及到加载网络图片。理论上来说我们可以直接继承ImageView来实现,可是这样太复杂了,是不可取的。所以头像跟我们现在所要实现效果是分开的。然后在跟头像组合在一起,这里可以使自定义一个ViewGroup把两者结合,我这里图省事,这里就没有去做了,而是直接在使用的时候,在布局里面组合在一起。 20 | 21 | 1. 所以第一步先不考虑头像而是实现TanTanRippleView.接下来看水波纹的实现: 22 | 我们需要的是,波纹是动态添加的,通过点击头像添加,所以需要暴露接口。并且波纹是有渐变的,越到边缘透明度越低,直到消失。每一个波纹都是一个圆,透明度通过改变Paint的颜色即可,透明度跟圆的半径也是有规律可循的。所以我这里把每个波纹做了封装。 23 | ``` 24 | inner class RippleCircle { 25 | // 4s * 60 frms = 240 26 | private val slice = 150 27 | var startRadius = 0f 28 | var endRadius = 0f 29 | var cx = 0f 30 | var cy = 0f 31 | 32 | private var progress = 0 33 | 34 | fun draw(canvas: Canvas) { 35 | if (progress >= slice) { 36 | // remove 37 | post { 38 | rippleCircles.remove(this) 39 | } 40 | return 41 | } 42 | progress++ 43 | ripplePaint.alpha = (1 - progress.div(slice * 1.0f)).times(255).toInt() 44 | val radis = startRadius + (endRadius - startRadius).div(slice).times(progress) 45 | canvas.drawCircle(cx, cy, radis, ripplePaint) 46 | } 47 | } 48 | ``` 49 | 看到以上代码可能对slice这个属性有疑惑,这是定义波纹持续时间的,如果60帧每秒,那么持续4s,总共是240帧。这里默认取150帧,所以在60帧持续的时间是2.5s.透明度和半径都跟slice有关: 50 | ``` 51 | ripplePaint.alpha = (1 - progress.div(slice * 1.0f)).times(255).toInt() 52 | val radis = startRadius + (endRadius - startRadius).div(slice).times(progress) 53 | ``` 54 | 随着时间的增长,透明度越低,半径越大。 55 | 56 | 怎么使用封装的RippleCircle。我们的要求是可以动态添加,并且消失之后需要移除,所以通过ArrayList来作为容器。但这里涉及到对集合的添加和删除操作,如果同时进行会发生异常。解决如下,使用CopyOnWriteArrayList,并且移除通过: 57 | ``` 58 | post { 59 | rippleCircles.remove(this) 60 | } 61 | ``` 62 | 然后在onDraw中,值得一提的是为了防止被扫描的部分挡住,这里的代码需要写在onDraw方法的后部分。 63 | ``` 64 | for (i in 0 until rippleCircles.size) { 65 | rippleCircles[i].draw(canvas) 66 | } 67 | ``` 68 | 69 | 在startRipple()方法中添加RippleCircle: 70 | ``` 71 | rippleCircles.add(RippleCircle().apply { 72 | cx = width.div(2).toFloat() 73 | cy = height.div(2).toFloat() 74 | val maxRadius = Math.min(width, height).div(2).toFloat() 75 | startRadius = maxRadius.div(3) 76 | endRadius = maxRadius 77 | }) 78 | ``` 79 | startRipple也是暴露出去调用添加波纹的方法。点击头像然后添加。涉及到自定义View当然测量是很关键的一部分。不过现在直接使用默认就可以,然后去宽高的最小值,除以2作为半径。在这里为什么startRadius要处以3呢,因为定义该大小作为波纹圆开始的半径。到这里第一步就算完成了。 80 | 81 | 2. 扫描的效果是关键的部分,而且效率直接影响是否可用。仔细看效果,其实也是一个圆只不过添加了shader。所以重点就是shader的实现。android中默认提供了几种Shader给我们使用。SweepGradient就是我们需要的,扫描渐变。然后选择了之后,就是调整参数了,看一下SweepGradient的用法: 82 | 构造函数 83 | ``` 84 | SweepGradient(float cx, float cy, 85 | @NonNull @ColorInt int colors[], @Nullable float positions[]) 86 | ``` 87 | 重点在于positions 的理解。按照文档解释以及代码。 88 | 比如跟colors 的值一一对应,还必须是单调递增的,防止出现严重异常。 89 | positions 对应每一个颜色的位置,当然是再圆的位置。顺时针,0为0°,0.5为180°,1为360°。 90 | 如果要像探探一样,最开始是一根线颜色很深。说明第一种颜色很深占比很小,第二种颜色浅占比很大,如下 91 | ``` 92 | val colors = intArrayOf(getColor(R.color.pink_fa758a),getColor(R.color.pink_f5b8c2),getColor(R.color.top_background_color),getColor(R.color.white)) 93 | 94 | SweepGradient(width.div(2).toFloat(), height.div(2).toFloat(), colors, floatArrayOf(0f,0.001f,0.9f,1f)) 95 | ``` 96 | 所以设置对了参数,整个扫描渐变的效果就差不多了。然后在对画笔设置shader,在drawCircle。 97 | ``` 98 | backPaint.setShader(SweepGradient(width.div(2).toFloat(), height.div(2).toFloat(), colors, floatArrayOf(0f, 0.001f, 0.9f, 1f))) 99 | canvas.drawCircle(width.div(2).toFloat(), height.div(2).toFloat(), radius, backPaint) 100 | 101 | ``` 102 | 当做完上面的操作之后,整个扫面的范围是整个圆,而需要的效果是中间有镂空的校园,这里又涉及到对xfermode的操作了。进行xfermode操作,必须要对canvas设置layer。如果不设置会有问题,镂空的校园是黑色的。详细的解释在我之间的文章中有[高仿QQ 发送图片高亮HaloProgressView](https://www.jianshu.com/p/0254501d744d)一文中做过阐述。setLayer需要设置范围,那么我们的范围就是覆盖整个大圆的矩形 103 | ``` 104 | val rectF = RectF(width.div(2f) - radius 105 | , height.div(2f) - radius 106 | , width.div(2f) + radius 107 | , height.div(2f) + radius) 108 | val sc = canvas.saveLayer(rectF, backPaint, Canvas.ALL_SAVE_FLAG) 109 | ``` 110 | 然后再drawCircle之后在设置xfermode 111 | ``` 112 | backPaint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.DST_OUT)) 113 | 114 | ``` 115 | 116 | 这里采取DST_OUT,为什么采用这种模式,在之前文章中可以详细查看[Paint Xfermode 详解](https://www.jianshu.com/p/19997b0b5b24).到这里扫描渐变和镂空都实现了,只差最后一步,转动起来。 117 | 转动直接通过canvas的rotate方法是很适合现在的场景。因为整个View都是圆。涉及到canvas操作,需要save,然后再restore 118 | ``` 119 | canvas.save() 120 | canvas.rotate(sweepProgress.toFloat(), width.div(2f), height.div(2f)) 121 | ... 122 | canvas.restore() 123 | ``` 124 | 可以看到sweepProgress是转动的关键,通过动画控制是很方便的。 125 | ``` 126 | private val renderAnimator by lazy { 127 | ValueAnimator.ofInt(0, 60) 128 | .apply { 129 | interpolator = LinearInterpolator() 130 | duration = 1000 131 | repeatMode = ValueAnimator.RESTART 132 | repeatCount = ValueAnimator.INFINITE 133 | addUpdateListener { 134 | postInvalidateOnAnimation() 135 | fps++ 136 | sweepProgress++ 137 | 138 | } 139 | addListener(object : AnimatorListenerAdapter() { 140 | override fun onAnimationRepeat(animation: Animator?) { 141 | super.onAnimationRepeat(animation) 142 | fps = 0 143 | } 144 | 145 | }) 146 | } 147 | } 148 | ``` 149 | 可以看到参数设置一秒60次执行。也就是60帧。再通过到了360°,置0即可。到这里已经完成了TanTanRippleView的实现。接着实现头像的动画。在头像的点击事件里面直接添加: 150 | ``` 151 | ((TanTanRippleView)findViewById(R.id.ripple)).startRipple(); 152 | AnimatorSet set = new AnimatorSet(); 153 | set.setInterpolator(new BounceInterpolator()); 154 | set.playTogether( 155 | ObjectAnimator.ofFloat(v,"scaleX",1.2f,0.8f,1f), 156 | ObjectAnimator.ofFloat(v,"scaleY",1.2f,0.8f,1f)); 157 | set.setDuration(1100).start(); 158 | ``` 159 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | 6 | android { 7 | compileSdkVersion 23 8 | buildToolsVersion '28.0.3' 9 | 10 | defaultConfig { 11 | applicationId "hewking.github.customviewdemo" 12 | minSdkVersion 19 13 | targetSdkVersion 23 14 | versionCode 1 15 | versionName "1.0" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | sourceSets { 24 | main.java.srcDirs += 'src/main/kotlin' 25 | } 26 | } 27 | 28 | dependencies { 29 | implementation fileTree(include: ['*.jar'], dir: 'libs') 30 | testImplementation 'junit:junit:4.12' 31 | implementation 'com.android.support:appcompat-v7:23.1.1' 32 | implementation 'com.android.support:design:23.1.1' 33 | api "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" 34 | 35 | } 36 | 37 | repositories { 38 | mavenCentral() 39 | } 40 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in E:\android\eclispe_android\eclipse_en_32_4.4\sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /app/src/androidTest/java/hewking/github/customviewdemo/ApplicationTest.java: -------------------------------------------------------------------------------- 1 | package hewking.github.customviewdemo; 2 | 3 | import android.app.Application; 4 | import android.test.ApplicationTestCase; 5 | 6 | /** 7 | * Testing Fundamentals 8 | */ 9 | public class ApplicationTest extends ApplicationTestCase { 10 | public ApplicationTest() { 11 | super(Application.class); 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/hewking/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.hewking; 2 | 3 | import android.animation.AnimatorSet; 4 | import android.animation.ObjectAnimator; 5 | import android.app.Activity; 6 | import android.os.Bundle; 7 | import android.util.Log; 8 | import android.view.View; 9 | import android.view.animation.BounceInterpolator; 10 | 11 | import com.hewking.widget.TanTanRippleView; 12 | 13 | import hewking.github.customviewdemo.R; 14 | 15 | public class MainActivity extends Activity { 16 | 17 | 18 | @Override 19 | protected void onCreate(Bundle savedInstanceState) { 20 | super.onCreate(savedInstanceState); 21 | setContentView(R.layout.activity_main); 22 | final int i = 10; 23 | getWindow().getDecorView().setOnClickListener(new View.OnClickListener() { 24 | @Override 25 | public void onClick(View v) { 26 | 27 | } 28 | }); 29 | initView(); 30 | } 31 | 32 | private void initView() { 33 | findViewById(R.id.iv_avatar).setOnClickListener(new View.OnClickListener() { 34 | @Override 35 | public void onClick(View v) { 36 | ((TanTanRippleView)findViewById(R.id.ripple)).startRipple(); 37 | AnimatorSet set = new AnimatorSet(); 38 | set.setInterpolator(new BounceInterpolator()); 39 | set.playTogether( 40 | ObjectAnimator.ofFloat(v,"scaleX",1.2f,0.8f,1f), 41 | ObjectAnimator.ofFloat(v,"scaleY",1.2f,0.8f,1f)); 42 | set.setDuration(1100).start(); 43 | } 44 | }); 45 | 46 | 47 | } 48 | 49 | 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/hewking/ViewEx.kt: -------------------------------------------------------------------------------- 1 | package com.hewking 2 | 3 | import android.support.annotation.ColorInt 4 | import android.support.annotation.ColorRes 5 | import android.support.v4.content.ContextCompat 6 | import android.view.View 7 | 8 | /** 9 | * 类的描述: 10 | * 创建人员:hewking 11 | * 创建时间:2018/12/27 12 | * 修改人员:hewking 13 | * 修改时间:2018/12/27 14 | * 修改备注: 15 | * Version: 1.0.0 16 | */ 17 | 18 | fun View.dp2px(dp : Float) : Int{ 19 | return (context.resources.displayMetrics.density * dp + 0.5).toInt() 20 | } 21 | 22 | @ColorInt 23 | fun View.getColor(@ColorRes resid: Int): Int { 24 | return ContextCompat.getColor(context, resid) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hewking/widget/CircleImageView.kt: -------------------------------------------------------------------------------- 1 | package com.hewking.widget 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.graphics.Color 6 | import android.graphics.Paint 7 | import android.graphics.Path 8 | import android.util.AttributeSet 9 | import android.widget.ImageView 10 | import com.hewking.dp2px 11 | import hewking.github.customviewdemo.R 12 | 13 | class CircleImageView(ctx : Context, attrs: AttributeSet) : ImageView(ctx,attrs) { 14 | 15 | private var borderWidth = dp2px(1f).toFloat() 16 | 17 | private val path by lazy { 18 | Path() 19 | } 20 | 21 | init { 22 | val typeArray = ctx.obtainStyledAttributes(attrs, R.styleable.CircleImageView) 23 | borderWidth = typeArray.getDimensionPixelSize(R.styleable.CircleImageView_c_border_width,borderWidth.toInt()).toFloat() 24 | typeArray.recycle() 25 | } 26 | 27 | private val mBorderPaint by lazy { 28 | Paint().apply{ 29 | isAntiAlias = true 30 | style = Paint.Style.STROKE 31 | color = Color.WHITE 32 | strokeWidth = borderWidth 33 | } 34 | } 35 | 36 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 37 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 38 | val size = Math.min(getMeasuredWidth(),getMeasuredHeight()) 39 | setMeasuredDimension(size,size) 40 | } 41 | 42 | override fun onDraw(canvas: Canvas?) { 43 | canvas?:return 44 | val radius = Math.min(width,height).div(2f) 45 | path.addCircle(width.div(2f),height.div(2f),radius,Path.Direction.CW) 46 | canvas.clipPath(path) 47 | canvas.drawCircle(width.div(2f),height.div(2f),radius,mBorderPaint) 48 | canvas.save() 49 | canvas.scale(0.9f,0.9f) 50 | super.onDraw(canvas) 51 | canvas.restore() 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/com/hewking/widget/TanTanRippleView.kt: -------------------------------------------------------------------------------- 1 | package com.hewking.widget 2 | 3 | import android.animation.Animator 4 | import android.animation.AnimatorListenerAdapter 5 | import android.animation.ValueAnimator 6 | import android.content.Context 7 | import android.graphics.* 8 | import android.support.v4.content.ContextCompat.getColor 9 | import android.util.AttributeSet 10 | import android.view.View 11 | import android.view.animation.LinearInterpolator 12 | import com.hewking.dp2px 13 | import com.hewking.getColor 14 | import hewking.github.customviewdemo.BuildConfig 15 | import hewking.github.customviewdemo.R 16 | 17 | import java.util.concurrent.CopyOnWriteArrayList 18 | 19 | /** 20 | * 项目名称:FlowChat 21 | * 类的描述:xfermode 的使用采用canvas.drawBitmap 的方式实现 22 | * 创建人员:hewking 23 | * 创建时间:2018/12/11 0011 24 | * 修改人员:hewking 25 | * 修改时间:2018/12/11 0011 26 | * 修改备注: 27 | * Version: 1.0.0 28 | */ 29 | class TanTanRippleView(ctx: Context, attrs: AttributeSet) : View(ctx, attrs) { 30 | 31 | private var radiuls: Int = 0 32 | 33 | private val rippleCircles = CopyOnWriteArrayList() 34 | 35 | init { 36 | 37 | } 38 | 39 | private val ripplePaint by lazy { 40 | Paint().apply { 41 | style = Paint.Style.STROKE 42 | strokeWidth = dp2px(0.5f).toFloat() 43 | color = getColor(R.color.color_FF434343) 44 | isAntiAlias = true 45 | } 46 | } 47 | 48 | private val backPaint by lazy { 49 | Paint().apply { 50 | style = Paint.Style.FILL 51 | isAntiAlias = true 52 | strokeWidth = dp2px(0.5f).toFloat() 53 | } 54 | } 55 | 56 | private var sweepProgress = 0 57 | set(value) { 58 | if (value >= 360) { 59 | field = 0 60 | } else { 61 | field = value 62 | } 63 | } 64 | private var fps: Int = 0 65 | private var fpsPaint = Paint().apply { 66 | isAntiAlias = true 67 | style = Paint.Style.STROKE 68 | color = Color.GREEN 69 | textSize = dp2px(20f).toFloat() 70 | strokeWidth = dp2px(1f).toFloat() 71 | } 72 | 73 | private val renderAnimator by lazy { 74 | ValueAnimator.ofInt(0, 60) 75 | .apply { 76 | interpolator = LinearInterpolator() 77 | duration = 1000 78 | repeatMode = ValueAnimator.RESTART 79 | repeatCount = ValueAnimator.INFINITE 80 | addUpdateListener { 81 | postInvalidateOnAnimation() 82 | fps++ 83 | sweepProgress++ 84 | 85 | } 86 | addListener(object : AnimatorListenerAdapter() { 87 | override fun onAnimationRepeat(animation: Animator?) { 88 | super.onAnimationRepeat(animation) 89 | fps = 0 90 | } 91 | 92 | }) 93 | } 94 | } 95 | 96 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 97 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 98 | val wMode = MeasureSpec.getMode(widthMeasureSpec) 99 | val wSize = MeasureSpec.getSize(widthMeasureSpec) 100 | val hMode = MeasureSpec.getMode(heightMeasureSpec) 101 | val hSize = MeasureSpec.getSize(heightMeasureSpec) 102 | val size = Math.min(wSize, hSize) 103 | if (wMode == MeasureSpec.AT_MOST || hMode == MeasureSpec.AT_MOST) { 104 | radiuls = size.div(2) 105 | } 106 | } 107 | 108 | var backCanvas: Canvas? = null 109 | var backBitmap: Bitmap? = null 110 | 111 | override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { 112 | super.onSizeChanged(w, h, oldw, oldh) 113 | backBitmap?.recycle() 114 | if (w != 0 && h != 0) { 115 | backBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) 116 | backCanvas = Canvas(backBitmap) 117 | } 118 | } 119 | 120 | override fun onDraw(canvas: Canvas?) { 121 | canvas ?: return 122 | 123 | val maxRadius = Math.min(width, height).div(2).toFloat() 124 | val radius = maxRadius 125 | canvas.save() 126 | canvas.rotate(sweepProgress.toFloat(), width.div(2f), height.div(2f)) 127 | 128 | val colors = intArrayOf(getColor(R.color.pink_fa758a), getColor(R.color.pink_f5b8c2), getColor(R.color.top_background_color), getColor(R.color.white)) 129 | backPaint.setShader(SweepGradient(width.div(2).toFloat(), height.div(2).toFloat(), colors, floatArrayOf(0f, 0.001f, 0.9f, 1f))) 130 | val rectF = RectF(width.div(2f) - radius 131 | , height.div(2f) - radius 132 | , width.div(2f) + radius 133 | , height.div(2f) + radius) 134 | val sc = canvas.saveLayer(rectF, backPaint, Canvas.ALL_SAVE_FLAG) 135 | // canvas.drawBitmap(makeDst(), null,rectF, backPaint) 136 | canvas.drawCircle(width.div(2).toFloat(), height.div(2).toFloat(), radius, backPaint) 137 | backPaint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.DST_OUT)) 138 | // canvas.drawCircle(width.div(2f), height.div(2f), radius.div(3f), backPaint) 139 | /* rectF.apply { 140 | left = width.div(2f) - radius * 1f.div(3) 141 | top = height.div(2f) - radius * 1f.div(3) 142 | right = width.div(2f) + radius * 1f.div(3) 143 | bottom = height.div(2f) + radius * 1f.div(3) 144 | } 145 | canvas.drawBitmap(makeSrc(),null,rectF,backPaint)*/ 146 | canvas.drawCircle(width.div(2f), height.div(2f), radius.div(3f), backPaint) 147 | backPaint.setXfermode(null) 148 | backPaint.setShader(null) 149 | canvas.restoreToCount(sc) 150 | canvas.restore() 151 | 152 | for (i in 0 until rippleCircles.size) { 153 | rippleCircles[i].draw(canvas) 154 | } 155 | 156 | if (BuildConfig.DEBUG) { 157 | canvas.drawText(fps.toString(), paddingStart.toFloat() 158 | , height - dp2px(10f).toFloat() - paddingBottom, fpsPaint) 159 | } 160 | } 161 | 162 | override fun onAttachedToWindow() { 163 | super.onAttachedToWindow() 164 | // start anim 165 | // startRipple() 166 | renderAnimator.start() 167 | } 168 | 169 | open fun startRipple() { 170 | val runnable = Runnable { 171 | rippleCircles.add(RippleCircle().apply { 172 | cx = width.div(2).toFloat() 173 | cy = height.div(2).toFloat() 174 | val maxRadius = Math.min(width, height).div(2).toFloat() 175 | startRadius = maxRadius.div(3) 176 | endRadius = maxRadius 177 | }) 178 | // startRipple() 179 | } 180 | postOnAnimation(runnable) 181 | // postOnAnimationDelayed(runnable, 2000) 182 | } 183 | 184 | override fun onDetachedFromWindow() { 185 | super.onDetachedFromWindow() 186 | // end anim 187 | renderAnimator.end() 188 | backBitmap?.recycle() 189 | } 190 | 191 | inner class RippleCircle { 192 | // 4s * 60 frms = 240 193 | private val slice = 150 194 | var startRadius = 0f 195 | var endRadius = 0f 196 | var cx = 0f 197 | var cy = 0f 198 | 199 | private var progress = 0 200 | 201 | fun draw(canvas: Canvas) { 202 | if (progress >= slice) { 203 | // remove 204 | post { 205 | rippleCircles.remove(this) 206 | } 207 | return 208 | } 209 | progress++ 210 | ripplePaint.alpha = (1 - progress.div(slice * 1.0f)).times(255).toInt() 211 | val radis = startRadius + (endRadius - startRadius).div(slice).times(progress) 212 | canvas.drawCircle(cx, cy, radis, ripplePaint) 213 | } 214 | } 215 | 216 | } -------------------------------------------------------------------------------- /app/src/main/java/com/hewking/widget/TideRippleView.kt: -------------------------------------------------------------------------------- 1 | package com.hewking.widget 2 | 3 | import android.animation.Animator 4 | import android.animation.AnimatorListenerAdapter 5 | import android.animation.ValueAnimator 6 | import android.content.Context 7 | import android.graphics.* 8 | import android.util.AttributeSet 9 | import android.view.View 10 | import android.view.animation.LinearInterpolator 11 | import com.hewking.dp2px 12 | import hewking.github.customviewdemo.BuildConfig 13 | import java.util.concurrent.CopyOnWriteArrayList 14 | 15 | /** 16 | * 项目名称:FlowChat 17 | * 类的描述: 18 | * 创建人员:hewking 19 | * 创建时间:2018/12/11 0011 20 | * 修改人员:hewking 21 | * 修改时间:2018/12/11 0011 22 | * 修改备注: 23 | * Version: 1.0.0 24 | */ 25 | class TideRippleView(ctx: Context, attrs: AttributeSet) : View(ctx, attrs) { 26 | 27 | private var radiuls: Int = 0 28 | 29 | private val rippleCircles = CopyOnWriteArrayList() 30 | 31 | init { 32 | 33 | } 34 | 35 | private val ripplePaint by lazy { 36 | Paint().apply { 37 | style = Paint.Style.STROKE 38 | strokeWidth = dp2px(0.5f).toFloat() 39 | color = Color.BLUE 40 | isAntiAlias = true 41 | } 42 | } 43 | 44 | private val backPaint by lazy { 45 | Paint().apply { 46 | style= Paint.Style.FILL_AND_STROKE 47 | isAntiAlias = true 48 | strokeWidth = dp2px(0.5f).toFloat() 49 | } 50 | } 51 | 52 | private var sweepProgress = 0 53 | set(value) { 54 | if (value >= 360) { 55 | field = 0 56 | } else { 57 | field = value 58 | } 59 | } 60 | private var fps: Int = 0 61 | private var fpsPaint = Paint().apply { 62 | isAntiAlias = true 63 | style = Paint.Style.STROKE 64 | color = Color.GREEN 65 | textSize = dp2px(20f).toFloat() 66 | strokeWidth = dp2px(1f).toFloat() 67 | } 68 | 69 | private val renderAnimator by lazy { 70 | ValueAnimator.ofInt(0, 60) 71 | .apply { 72 | interpolator = LinearInterpolator() 73 | duration = 1000 74 | repeatMode = ValueAnimator.RESTART 75 | repeatCount = ValueAnimator.INFINITE 76 | addUpdateListener { 77 | postInvalidateOnAnimation() 78 | fps++ 79 | sweepProgress ++ 80 | 81 | } 82 | addListener(object : AnimatorListenerAdapter() { 83 | override fun onAnimationRepeat(animation: Animator?) { 84 | super.onAnimationRepeat(animation) 85 | fps = 0 86 | } 87 | 88 | }) 89 | } 90 | } 91 | 92 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 93 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 94 | val wMode = MeasureSpec.getMode(widthMeasureSpec) 95 | val wSize = MeasureSpec.getSize(widthMeasureSpec) 96 | val hMode = MeasureSpec.getMode(heightMeasureSpec) 97 | val hSize = MeasureSpec.getSize(heightMeasureSpec) 98 | val size = Math.min(wSize, hSize) 99 | if (wMode == MeasureSpec.AT_MOST || hMode == MeasureSpec.AT_MOST) { 100 | radiuls = size.div(2) 101 | } 102 | } 103 | 104 | var backCanvas : Canvas? = null 105 | var backBitmap : Bitmap? = null 106 | 107 | override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { 108 | super.onSizeChanged(w, h, oldw, oldh) 109 | backBitmap?.recycle() 110 | if (w != 0 && h != 0){ 111 | backBitmap = Bitmap.createBitmap(w,h,Bitmap.Config.ARGB_8888) 112 | backCanvas = Canvas(backBitmap) 113 | } 114 | } 115 | 116 | override fun onDraw(canvas: Canvas?) { 117 | canvas ?: return 118 | canvas.save() 119 | // canvas.translate(width.div(2f),height.div(2f)) 120 | /* rippleCircles.forEach { 121 | }*/ 122 | 123 | for (i in 0 until rippleCircles.size) { 124 | rippleCircles[i].draw(canvas) 125 | } 126 | canvas.restore() 127 | backCanvas?.let { 128 | val maxRadius = Math.min(width, height).div(2).toFloat() 129 | val radius = maxRadius.div(2) 130 | it.save() 131 | // backPaint.color = Color.WHITE 132 | backPaint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.CLEAR)) 133 | it.drawPaint(backPaint) 134 | backPaint.setXfermode(null) 135 | it.rotate(sweepProgress.toFloat(),width.div(2f),height.div(2f)) 136 | backPaint.setShader(SweepGradient(width.div(2).toFloat(),height.div(2).toFloat(),Color.RED,Color.WHITE)) 137 | it.drawCircle(width.div(2).toFloat(),height.div(2).toFloat(),radius,backPaint) 138 | backPaint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.SRC_OUT)) 139 | // backPaint.color = Color.TRANSPARENT 140 | it.drawCircle(width.div(2f),height.div(2f),radius.div(3f),backPaint) 141 | it.restore() 142 | canvas.drawBitmap(backBitmap,0f,0f,null) 143 | } 144 | 145 | 146 | if (BuildConfig.DEBUG) { 147 | canvas.drawText(fps.toString(), paddingStart.toFloat() 148 | , height - dp2px(10f).toFloat() - paddingBottom, fpsPaint) 149 | } 150 | } 151 | 152 | 153 | override fun onAttachedToWindow() { 154 | super.onAttachedToWindow() 155 | // start anim 156 | startRipple() 157 | renderAnimator.start() 158 | } 159 | 160 | private fun startRipple() { 161 | val runnable = Runnable { 162 | rippleCircles.add(RippleCircle().apply { 163 | cx = width.div(2).toFloat() 164 | cy = height.div(2).toFloat() 165 | val maxRadius = Math.min(width, height).div(2).toFloat() 166 | startRadius = maxRadius.div(2) 167 | endRadius = maxRadius 168 | }) 169 | startRipple() 170 | } 171 | postOnAnimationDelayed(runnable, 2000) 172 | } 173 | 174 | override fun onDetachedFromWindow() { 175 | super.onDetachedFromWindow() 176 | // end anim 177 | renderAnimator.end() 178 | backBitmap?.recycle() 179 | } 180 | 181 | inner class RippleCircle { 182 | // 4s * 60 frms = 240 183 | private val slice = 300 184 | var startRadius = 0f 185 | var endRadius = 0f 186 | var cx = 0f 187 | var cy = 0f 188 | 189 | private var progress = 0 190 | 191 | fun draw(canvas: Canvas) { 192 | if (progress >= slice) { 193 | // remove 194 | post { 195 | rippleCircles.remove(this) 196 | } 197 | return 198 | } 199 | progress++ 200 | ripplePaint.alpha = (1 - progress.div(slice * 1.0f)).times(255).toInt() 201 | val radis = startRadius + (endRadius - startRadius).div(slice).times(progress) 202 | canvas.drawCircle(cx, cy, radis, ripplePaint) 203 | } 204 | } 205 | 206 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_dbtest.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 |