ConstraintLayout+ViewPager2打造《摇一摇新年幸运签》App

1,975 阅读7分钟

“ PK创意闹新春,我正在参加「春节创意投稿大赛」,详情请看:春节创意投稿大赛

话不多说直接上图

动画.gif

  • 从图中可知,分为上下两部分,上部分是传统春联和福字,代表对大家的新年祝福,下部分是主要功能模块,包含红包金额、新年幸运签和是与不是
  • 采用Kotlin语言进行编写,涉及到的技术有:ConstraintLayoutDrawable自定义ViewAndroid动画Viewpager2字体的设置传感器的使用

    创意来源

  • 这个创意的来源,主要是年纪大了,过年肯定要给侄子侄女发红包,哈哈哈,这回金额可以他们自己摇出来,具有互动和随机性比较好玩,为新年增添一份乐趣
  • 新年幸运签是给大家的祝福
  • 大家肯定会有很多场景,会产生选择困难,所以是与不是这个模块就是解决此类问题添加的!
  • 新年也要动起来呀,刚好传统的摇签可以用手机摇一摇来模拟效果,活动手腕一举两得(真是个好点子啊)!
  • 安卓手机的小伙伴可以下载安装包 体验一把,我是停不下来

正文开始啦

  • 首先这个布局看起来挺简单的对吧,LinearLayout设置方向vertical,中间在用一个LinearLayout设置方向horizontal
    • 但是这就产生了一个问题,布局嵌套,所以这也是我为什么采用ConstraintLayout来实现的原因,如下图,只用了一层

image.png

ConstraintLayout使用

  • 啰嗦两句,有的小伙伴可以没用过,可以参考下
    • ConstraintLayout中的控件横竖两个方向至少要选择一个进行约束,否则控件将在左上角进行摆放
    • top指顶部bottom指底部start指左边end指右边,上例子
<com.android.springfestival.view.SpringTextView
    android:id="@+id/top"
    android:layout_width="wrap_content"
    android:layout_height="?actionBarSize"
    android:background="@drawable/shape_red_solid"
    android:gravity="center"
    android:paddingLeft="10dp"
    android:paddingRight="10dp"
    android:text="金 虎 迎 福"
    android:textSize="24sp"
    android:textStyle="bold"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

  • 这是横批的文字,可以看到,它的顶部父布局的顶部相约束左边父布局的左边相约束右边父布局的右边相约束
    • 横向居中需要左右都加约束,不需要的话,想让控件在哪个方向开始摆放,就让它约束到该方向,如横批靠顶部摆放
  • 接下来我想让上部分占百分之七十下部分占百分之三十
    • 添加Guideline控件上下分的话设置orientation为horizontal,想要左右分改为vertical即可。
    • layout_constraintGuide_percent属性用来设置上或左占多少,数值范围为0到1

image.png

<androidx.constraintlayout.widget.Guideline
    android:id="@+id/guideline"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    app:layout_constraintGuide_percent="0.7" />
  • 中间的对联字,我打算字占百分之三十,剩下的各占百分之十,所以控件宽高都设置了0dp,即占满剩余空间,为它们设置横向权重1:3:1
    • 横向权重app:layout_constraintHorizontal_weight
    • 福字因为要宽高一一致,设置比例1:1即可app:layout_constraintDimensionRatio="1:1"
    • 需要注意:控件之间必须相互依赖才起作用。
  • 代码如下:
<com.android.springfestival.view.VerticalTextView
   android:id="@+id/left"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:layout_marginLeft="5dp"
   android:background="@drawable/shape_red_solid"
   android:ems="1"
   android:paddingTop="10dp"
   android:paddingBottom="10dp"
   android:text="迎春迎福迎富贵"
   android:textSize="24sp"
   android:textStyle="bold"
   app:layout_constraintHorizontal_weight="1"
   app:layout_constraintBottom_toTopOf="@id/guideline"
   app:layout_constraintEnd_toStartOf="@id/ling"
   app:layout_constraintStart_toStartOf="parent"
   app:layout_constraintTop_toBottomOf="@id/top" />

<com.android.springfestival.view.VerticalTextView
   android:id="@+id/right"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:layout_marginRight="5dp"
   android:background="@drawable/shape_red_solid"
   android:ems="1"
   android:paddingTop="10dp"
   android:paddingBottom="10dp"
   android:text="接财接福接平安"
   android:textSize="24sp"
   android:textStyle="bold"
   app:layout_constraintHorizontal_weight="1"
   app:layout_constraintBottom_toBottomOf="@id/guideline"
   app:layout_constraintEnd_toEndOf="parent"
   app:layout_constraintStart_toEndOf="@id/ling"
   app:layout_constraintTop_toBottomOf="@id/top" />

<com.android.springfestival.view.DiamondTextView
   android:id="@+id/ling"
   android:layout_width="0dp"
   app:layout_constraintDimensionRatio="1:1"
   app:layout_constraintHorizontal_weight="3"
   android:layout_height="0dp"
   android:layout_margin="5dp"
   android:autoSizeTextType="uniform"
   android:gravity="center"
   android:text="福"
   android:textStyle="bold"
   app:layout_constraintBottom_toTopOf="@+id/OptionVp"
   app:layout_constraintEnd_toStartOf="@id/right"
   app:layout_constraintStart_toEndOf="@id/left"
   app:layout_constraintTop_toBottomOf="@id/top"
   />
  • 下面的ViewPager2指示器采用权重,将剩余的空间按4:1进行分配,这里权重LinearLayout的用法一致。
<androidx.viewpager2.widget.ViewPager2
    android:id="@+id/OptionVp"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    app:layout_constraintBottom_toTopOf="@id/llPointContainer"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/guideline"
    app:layout_constraintVertical_weight="4" />

<LinearLayout
    android:id="@+id/llPointContainer"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_marginTop="10dp"
    android:gravity="center_horizontal"
    android:orientation="horizontal"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/OptionVp"
    app:layout_constraintVertical_weight="1" />

Drawable使用

  • 这里用的是ShapeDrawable
    • 他的好处就是可以为控件添加背景减少图片资源的使用,从而降低包体积大小

image.png

  • 上图中的对联描边金线均来自ShapeDrawable,代码如下
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">

 <stroke
     android:width="1dp"
     android:color="@color/colorGold" />
 <solid android:color="@color/colorRed" />
</shape>

自定义View

横批

  • 不知道小伙伴有没有发现,字体不是系统自带的字体,我们要改变字体,最简单的方法是继承TextView,重写他的setTypeface方法

image.png

  • 新建如上图目录,放入我们需要的字体
  • 使用这个字体,并传给父类
  • 布局文件使用,代码在ConstraintLayout章节中。
class SpringTextView(context: Context?, attrs: AttributeSet?) :
    AppCompatTextView(context, attrs) {
    //重写设置字体方法
    override fun setTypeface(tf: Typeface?) {
        super.setTypeface(Typeface.createFromAsset(context.assets, "fonts/hwxk.ttf"))
    }
}

对联

  • 相信大家都清楚,TextView可以用android:ems="1"达到竖直排列,但是紧贴在一起不能均分非常不美观,所以我们继续继承TextView,自定义竖直均分的效果
    • 这次我们重写onDraw方法,自己来进行文字的绘制
    • 高度绘制的关键点在于算出每个文字之间的空隙总的高度减去上下的padding和文字的宽度除以文字的个数减一
    • 宽度绘制(总的宽度减去文字的宽除以二
  • 代码如下:
override fun onDraw(canvas: Canvas) {
    paint.textSize = textSize
    paint.apply {
        typeface = Typeface.createFromAsset(context.assets,"fonts/hwxk.ttf")
    }
    var textLengthHeight = 0
    val r = Rect()
    val arr = IntArray(text.length)
    canvasLength = measuredHeight - paddingTop - paddingBottom
    if (!TextUtils.isEmpty(text) && text.length > 1) {
        var i = 0
        while (i < text.length) {
            paint.getTextBounds(text.substring(i, i + 1), 0, 1, r)
            textLengthHeight += (r.bottom - r.top)
            arr[i] = r.bottom - r.top
            i++
        }
        space = (canvasLength - textLengthHeight).toDouble() / (text.length - 1)
    }
    var arrlength = 0f
    var i = 0
    while (i < text.length) {
        arrlength += arr[i]
        if (i == 0) {
            canvas.drawText(
                text.substring(i, i + 1),
                ((width - r.right - r.left) / 2).toFloat(),
                (i * space + arrlength).toFloat() + paddingTop,
                paint
            )
        } else {
            canvas.drawText(
                text.substring(i, i + 1),
                ((width - r.right - r.left) / 2).toFloat(),
                (i * space + arrlength).toFloat(),
                paint
            )
        }
        i++
    }
}

福字

  • 菱形的TextView系统也没给咱,咋办呢,继续自定义!
    • 老规矩重写onDraw,获取宽高,取最短的,利用Path画一个出来,在为TextView设置背景即可。
    • 这里我画了两次,因为福字怎么能少了金边呢!
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)
   var min = min(width, height)
   var mPath = Path().apply {
       moveTo(0F, (min / 2).toFloat());
       lineTo((min / 2).toFloat(), 0F);
       lineTo(min.toFloat(), (min / 2).toFloat());
       lineTo((min / 2).toFloat(), min.toFloat());
       close();
   }
   val bmp = Bitmap.createBitmap(min, min, Bitmap.Config.ARGB_8888)
   val c = Canvas(bmp)
   c.drawPath(mPath, paint)
   c.drawPath(mPath, paintStock)
   setBackgroundDrawable(BitmapDrawable(resources, bmp))

ViewPager2

无限滑动的实现

  • 数据源的第一位add最后一张图
val newList = arrayListOf<String>()
newList.add(pic[pic.size-1])
  • 最后一位添加第一张图
for (item in pic) {
    newList.add(item)
}
newList.add(pic[0])
  • ViewPager2滑动到第0位最后一位时的处理分别如下
位置处理
currentPosition == 0setCurrentItem(adapter.itemCount - 2, false)
currentPosition == adapter.itemCount - 1setCurrentItem(1, false)
  • ViewPager2添加滑动监听代码如下 关键点在onPageScrollStateChanged方法
bannerVp.registerOnPageChangeCallback(object :
    ViewPager2.OnPageChangeCallback() {
    override fun onPageSelected(position: Int) {
        currentPosition = position
    }
    override fun onPageScrollStateChanged(state: Int) {
        //只有在空闲状态,才让自动滚动
        if (state == ViewPager2.SCROLL_STATE_IDLE) {
            if (currentPosition == 0) {
                bannerVp.setCurrentItem(adapter.itemCount - 2, false)
            } else if (currentPosition == adapter.itemCount - 1) {
                bannerVp.setCurrentItem(1, false)
            }
        }
    }
})

item的形状

  • 这里也是通过ShapeDrawable来实现的,代码如下
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <corners
        android:bottomRightRadius="50dp"
        android:topLeftRadius="50dp" />
    <stroke
        android:width="1dp"
        android:color="@color/colorGold" />
    <solid android:color="#F02A2A" />
</shape>

指示器的添加

  • 根据数量动态创建View,代码如下:
private fun initIndicator(){
    llPointContainer.removeAllViews()
    for (index in 1..size-2) {
        val view = View(this)
        val layoutParams = LinearLayout.LayoutParams(10.dp.toInt(), 10.dp.toInt())
        layoutParams.marginEnd = 8
        layoutParams.marginStart = 8
        view.layoutParams = layoutParams
        llPointContainer.addView(view)
    }
}
  • 滑动的时候更新指示器背景
    • ViewPager2的滑动监听的onPageSelected方法中调用如下方法即可
      • 记得做如下判断
if (position <= llPointContainer.childCount) updateIndicator(position)
private fun updateIndicator(position: Int){
    llPointContainer.run {
        for (index in 1..childCount) {
            getChildAt(index - 1).background = resources.getDrawable(R.drawable.circlered)
        }
        if (position > 0) {
            getChildAt(position - 1).background = resources.getDrawable(R.drawable.circlegold)
        }
    }
}
  • 这里的形状也是通过ShapeDrawable实现的。

ViewPager2一屏多页效果

  • 这里和ViewPager一屏多页有很大区别ViewPager采用为给自身设置margin并设置clipChildren属性为false
  • ViewPager2则是通过给RecyclerView设置PaddingPageTransformer的方式来实现
OptionVp.apply {
 offscreenPageLimit=1
 val recyclerView= getChildAt(0) as RecyclerView
 recyclerView.apply {
     val padding = resources.getDimensionPixelOffset(R.dimen.common_line_height) +
             resources.getDimensionPixelOffset(R.dimen.common_line_height)
     // setting padding on inner RecyclerView puts overscroll effect in the right place
     setPadding(padding, 0, padding, 0)
     clipToPadding = false
 }
}

ViewPager2滑动缩放

  • 说到这就要讲一下PageTransformer了,它可以用来设置页面动画,还可以设置页面间距间距和动画都要的话就要用到CompositePageTransformer了。
  • 我这里如上一条,设置了页面间距并且用到了缩放效果,那么来看一下具体代码
val compositePageTransformer = CompositePageTransformer()
compositePageTransformer.addTransformer(ScaleInTransformer())
compositePageTransformer.addTransformer(
    MarginPageTransformer(
        resources.getDimension(R.dimen.common_margin_middle).toInt()
    )
)
OptionVp.setPageTransformer(compositePageTransformer)
  • 是不是很香,快用起来吧

传感器

  • Android中有很多传感器,这里我们用到的是加速度传感器,使用步骤如下:
    • 获取传感器管理者对象
    • 获取加速度传感器对象
    • 注册传感器(onCreate中调用)
    • 解除传感器(onDestory中调用)
sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
sensor = sensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager!!.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL)
sensorManager!!.unregisterListener(this)
  • 注册了监听器之后在onSensorChanged方法中做业务的判断(这里采用获取event.values大于15),符合业务条件就调用震动弹出提示框
override fun onSensorChanged(event: SensorEvent) {
        /* 当传感器数值发生改变时调用的函数*/
        val values: FloatArray = event.values
        val x = values[0]
        val y = values[1]
        val z = values[2]
        val minValue = 15
        if(!isShake) {
            if (Math.abs(x) > minValue || Math.abs(y) > minValue || Math.abs(z) > minValue) {
                //开始震动
                isShake = true
                val pattern = longArrayOf(300, 500)
                vibrator!!.vibrate(pattern, -1)
                //开始动画效果
                MyDialog(this)
                    .showDialog(currentPosition - 1)

            }
        }
    }

震动的实现

  • 震动需要在manifest文件中申请权限
  • 获取振动器管理者对象
  • 调用vibrate开启震动
<!-- 振动器使用权限-->
<uses-permission android:name="android.permission.VIBRATE"/>
//获取振动器管理者对象
vibrator = getSystemService(VIBRATOR_SERVICE) as Vibrator
//开启震动
val pattern = longArrayOf(300, 500)
vibrator!!.vibrate(pattern, -1)

Android动画

  • 这里我们使用的是View动画Dialog添加入场和退场动画。 * View动画有如平移、缩放、旋转和透明度,这里使用了缩放。 |标签| 含义 | | :-------- | :--------| |interpolator| 指定动画插入器,常见的有加速减速插入器accelerate_decelerate_interpolator,加速插入器elerate_interpolator,减速插入器decelerate_interpolator。| |pivotX| 横向动画起始位置,相对于屏幕的百分比,50%表示动画从屏幕中间开始| |pivotY| 纵向动画起始位置,相对于屏幕的百分比,50%表示动画从屏幕中间开始| |fromXScale| 横向动画开始前的缩放,0.0为不显示,1.0为正常大小| |toXScale| 横向动画最终缩放的倍数,1.0为正常大小,大于1.0放大| |fromYScale| 纵向动画开始前的缩放,0.0为不显示,1.0为正常大小| |toYScale| 纵向动画最终缩放的倍数,1.0为正常大小,大于1.0放大|
  • 有了以上说明接下来的入场动画,和出场动画就更方便理解
    • 中心位置从零到一进行缩放
<!-- 弹出时动画 -->
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <scale
        android:interpolator="@android:anim/accelerate_interpolator"
        android:fromXScale="0.0"
        android:toXScale="1.0"
        android:fromYScale="0.0"
        android:toYScale="1.0"
        android:pivotX="50%"
        android:pivotY="50%"
        android:fillAfter="false"
        android:duration="400"/>
</set>
<!-- 退出时动画效果 -->
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <scale
        android:interpolator="@android:anim/accelerate_interpolator"
        android:fromXScale="1.0"
        android:toXScale="0.0"
        android:fromYScale="1.0"
        android:toYScale="0.0"
        android:pivotX="50%"
        android:pivotY="50%"
        android:fillAfter="false"
        android:duration="400"/>
</set>
  • 退出是从一到零回退到中心位置

随机结果

  • 这里并没有网络请求,采用将答案写在本地随机抽取展示。
    • 随机的代码在Kotlin中很简单如下
(answerList.indices).random()
  • 本来想加数据库,支持人为输入的,后期慢慢实现吧。

最后

祝各位工程师,虎年大吉,2022年心想事成,想法几经改版,差点流产,还好最后坚持做了出来。

写作不易,如果对你有一丢丢帮助或启发,感谢点赞支持,有问题也欢迎留言交流哦!