다양한 Animation 샘플(크기, 회전, Fade In/Out)

|
package com.snowdeer.animation.sample.fragment

import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.os.Bundle
import android.os.Handler
import android.view.*
import androidx.core.animation.doOnEnd
import androidx.fragment.app.Fragment
import com.snowdeer.animation.sample.R
import kotlinx.android.synthetic.main.fragment_voice_bubble.view.*


class VoiceBubbleFragment : Fragment() {

    private val handler = Handler()

    private var degree = 0
    private var isAnimating = false

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {

        val view = inflater.inflate(R.layout.fragment_voice_bubble, container, false)

        playScaleLoopAnimation(view.voice_indicator_1)

        handler.postDelayed({
            playScaleLoopAnimation(view.voice_indicator_2)

        }, 500)

        view.rotate_button.setOnClickListener {
            rotate(view.voice_layout) {}
        }

        view.show_bubble_button.setOnClickListener {
            if ((degree == 0) || (degree == 180)) {
                rotate(view.voice_layout) { fadeIn(view.question_layout) }
            } else {
                fadeIn(view.question_layout)
            }
        }

        view.hide_bubble_button.setOnClickListener {
            if ((degree == 90) || (degree == 270)) {
                rotate(view.voice_layout) {}
            }
            fadeOut(view.question_layout)
        }

        return view
    }

    private fun playScaleLoopAnimation(target: View) {
        val scaleUpX = ObjectAnimator.ofFloat(target, "scaleX", 2.5f)
        val scaleUpY = ObjectAnimator.ofFloat(target, "scaleY", 2.5f)

        scaleUpX.apply {
            duration = 1000
            repeatMode = ObjectAnimator.REVERSE
            repeatCount = ObjectAnimator.INFINITE
        }

        scaleUpY.apply {
            duration = 1000
            repeatMode = ObjectAnimator.REVERSE
            repeatCount = ObjectAnimator.INFINITE
        }

        val scaleDown = AnimatorSet()
        scaleDown.play(scaleUpX).with(scaleUpY)

        scaleDown.start()
    }

    private fun rotate(target: ViewGroup, nextAnim: () -> Unit) {
        if (!isAnimating) {
            isAnimating = true

            val targetDegree = degree + 90
            val rotateAnimator = ObjectAnimator.ofFloat(target,
                    "rotation", degree.toFloat(), targetDegree.toFloat())

            rotateAnimator.duration = 1000
            rotateAnimator.doOnEnd {
                degree = targetDegree
                isAnimating = false

                nextAnim()

            }
            rotateAnimator.start()
        }
    }

    private fun fadeIn(target: ViewGroup) {
        val animator = ObjectAnimator.ofFloat(target, View.ALPHA, 0F, 1f)
        animator.duration = 1000
        animator.start()
        target.visibility = View.VISIBLE
    }

    private fun fadeOut(target: ViewGroup) {
        val animator = ObjectAnimator.ofFloat(target, View.ALPHA, 1F, 0f)
        animator.duration = 1000
        animator.start()
    }
}

Canvas에 Arc 그리기

|
package com.snowdeer.animation.sample.component

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View

private const val THICKNESS = 20F


class CircleIndicatorView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    var min = 0
    var max = 1
    var progress = 0

    private val paint = Paint()
    private val erasePaint = Paint()

    init {
        paint.apply {
            color = Color.WHITE
            isAntiAlias = true
            style = Paint.Style.STROKE
            strokeJoin = Paint.Join.ROUND
            strokeCap = Paint.Cap.ROUND
            strokeWidth = THICKNESS
        }
    }

    override fun onDraw(canvas: Canvas?) {
        val centerX = measuredWidth / 2
        val centerY = measuredHeight / 2

        val rectF = RectF(0F, 0F, measuredWidth.toFloat(), measuredHeight.toFloat())

        val path = Path()
        path.arcTo(rectF, 103F, 2F)
        canvas?.drawPath(path, paint)
    }
}

Jacoco plugin 사용 방법

|

jacoco는 UnitTest 및 Coverage 레포트를 만들어주는 플러그인(plug-in)입니다.

build.gradle에 다음과 같이 작성하면 gradle 옵션에서 jacoco 레포트 생성을 선택할 수 있습니다.


build.gradle (Module)

excludes는 레포트에서 제외할 파일이나 디렉토리, 패키지입니다. Kotlin이나 Java의 환경에 따라 classDirectories의 디렉토리 위치는 변경될 수 있습니다.

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'jacoco'

jacoco {
    toolVersion = '0.8.4'
}

tasks.withType(Test) {
    jacoco.includeNoLocationClasses = true
}

task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest', 'createDebugCoverageReport']) {

    reports {
        xml.enabled = true
        html.enabled = true
    }

    def excludes = [
            '**/R.class',
            '**/R$*.class',
            '**/BuildConfig.*',
            '**/Manifest*.*',
            'android/**/*.*',
            '**/treemanager/component/**',
            '**/treemanager/tree/*.*',
            '**/activity/**'
    ]

    classDirectories = fileTree(
            dir: "$buildDir/intermediates/javac/debug/compileDebugJavaWithJavac/classes",
            excludes: excludes
    ) + fileTree(
            dir: "$buildDir/tmp/kotlin-classes/debug",
            excludes: excludes
    )

    sourceDirectories = files([
            android.sourceSets.main.java.srcDirs,
            "src/main/kotlin"
    ])

    executionData = fileTree(dir: project.buildDir, includes: [
            'jacoco/testDebugUnitTest.exec', 'outputs/code_coverage/debugAndroidTest/connected/**/*.ec'
    ])
}

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.snowdeer.jacoco.example"
        minSdkVersion 26
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
        buildConfigField "long", "TIMESTAMP", System.currentTimeMillis() + "L"
    }
    buildTypes {
        debug {
            testCoverageEnabled true
        }

        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    packagingOptions {
        pickFirst 'lib/arm64-v8a/*'
        pickFirst 'lib/armeabi-v7a/*'
    }
    buildToolsVersion '28.0.3'
}

그런 다음 gradle 옵션에서 jacocoTestReport 항목을 선택하면 레포트가 만들어집니다.

Directory Picker 구현해보기

|

팝업 형태의 다이얼로그(Dialog)로 실행하는 Directory Picker 입니다.

dialog_directory_picker.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:orientation="vertical">

  <FrameLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/colorPrimaryDark">

    <TextView
      android:textStyle="bold"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_marginTop="8dp"
      android:layout_marginBottom="8dp"
      android:layout_marginLeft="12dp"
      android:text="@string/directory_picker_title"
      android:textColor="@color/colorDialogTitle"
      android:textSize="24sp"/>

  </FrameLayout>

  <FrameLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/colorCurrentPathBackground">

    <TextView
      android:id="@+id/current_path"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_marginTop="4dp"
      android:layout_marginBottom="4dp"
      android:layout_marginLeft="12dp"
      android:text="current directory path"
      android:textColor="@color/colorCurrentPathText"
      android:textSize="16dp"/>

  </FrameLayout>

  <android.support.v7.widget.RecyclerView
    android:id="@+id/recycler_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginTop="16dp"
    android:minHeight="320dp"/>

</LinearLayout>


DirectoryListAdapter.kt

import android.content.Context
import android.os.Environment
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.snowdeer.ftpserver.R
import kotlinx.android.synthetic.main.item_directory.view.*
import java.io.File

interface OnDirectoryListEventListener {
    fun onDirectoryChanged(path: String)
}

data class DirectoryItem(val name: String, val path: String)

class DirectoryListAdapter(private val ctx: Context) : RecyclerView.Adapter<ViewHolder>() {

    private val list = ArrayList<DirectoryItem>()
    private val root = Environment.getExternalStorageDirectory().absolutePath
    var currentPath: String = ""

    var onDirectoryListEventListener: OnDirectoryListEventListener? = null

    fun refresh(path: String) {
        list.clear()

        val file = File(path)
        currentPath = file.path
        onDirectoryListEventListener?.onDirectoryChanged(path)

        addParentDirectory(file)

        if (!file.exists()) {
            notifyDataSetChanged()
            return
        }

        val files = file.listFiles()
        files?.let {
            for (f in it) {
                if (f.isHidden) {
                    continue
                }

                if (f.isDirectory) {
                    list.add(DirectoryItem(f.name, f.absolutePath))
                }
            }
        }

        notifyDataSetChanged()
    }

    private fun addParentDirectory(file: File) {
        val path = file.path
        if ((path == "/") || (path == root)) {
            return
        }

        list.add(DirectoryItem("..", file.parentFile.path))
    }

    override fun onCreateViewHolder(parent: ViewGroup, position: Int): ViewHolder {
        val view: View = LayoutInflater.from(ctx).inflate(R.layout.item_directory, parent, false)
        return ViewHolder(view)
    }

    fun getItem(position: Int): DirectoryItem {
        return list[position]
    }

    override fun getItemCount(): Int {
        return list.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(holder.adapterPosition)

        holder.name.text = item.name

        holder.layout_item.setOnClickListener {
            refresh(item.path)
        }
    }
}

class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    val layout_item = view.layout_item!!
    val name = view.name!!
}


DirectoryPickerDialog.kt

import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v4.app.FragmentActivity
import android.support.v7.app.AlertDialog
import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.widget.Toast
import com.snowdeer.ftpserver.R
import com.snowdeer.ftpserver.SnowPreference
import kotlinx.android.synthetic.main.dialog_directory_picker.view.*
import kotlinx.android.synthetic.main.item_directory.view.*
import java.io.File
import java.util.*

interface OnDirectoryPickerEventListener {
    fun onDirectorySelected(path: String)
}

class DirectoryPickerDialog : DialogFragment() {

    private lateinit var adapter: DirectoryListAdapter

    var onDirectoryPickerEventListener: OnDirectoryPickerEventListener? = null

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val builder = AlertDialog.Builder(Objects.requireNonNull<FragmentActivity>(activity))
        val inflater = activity!!.layoutInflater
        val view = inflater.inflate(R.layout.dialog_directory_picker, null)

        adapter = DirectoryListAdapter(activity!!)
        adapter.onDirectoryListEventListener = object : OnDirectoryListEventListener {
            override fun onDirectoryChanged(path: String) {
                view.current_path.text = path
            }
        }

        view.recycler_view.layoutManager = LinearLayoutManager(activity)
        view.recycler_view.adapter = adapter
        adapter.refresh(SnowPreference.getDirectory(activity!!))

        builder.setView(view)
                .setPositiveButton(getString(R.string.directory_picker_ok), null)
                .setNeutralButton(getString(R.string.directory_picker_new_directory), null)

        val dialog = builder.create()

        dialog.setOnShowListener {
            dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
                onDirectoryPickerEventListener?.onDirectorySelected(adapter.currentPath)
                dialog.dismiss()
            }

            dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener {
                showNewDirectoryDialog(adapter.currentPath)
            }
        }

        return dialog
    }

    private fun showNewDirectoryDialog(path: String) {
        val view = LayoutInflater.from(activity).inflate(R.layout.dialog_new_directory, null)

        AlertDialog.Builder(activity!!)
                .setTitle(getString(R.string.directory_picker_new_directory))
                .setView(view)
                .setPositiveButton("Ok") { _, _ ->
                    val newPath = path + "/" + view.name.text.toString()
                    if (createDirectory(newPath)) {
                        adapter.refresh(path)
                    } else {
                        Toast.makeText(activity!!, getString(R.string.new_directory_failed), Toast.LENGTH_SHORT).show()
                    }

                }
                .show()
    }

    private fun createDirectory(path: String): Boolean {
        val file = File(path)

        if (file.exists()) {
            return false
        }

        return file.mkdir()
    }
}

Network 상태 모니터링

|

안드로이드 버전에 따라 모니터링 방법이 조금씩 다르기 때문에 코드 내에서 분기를 태워줍니다.

AndroidManifest.xml

먼저 AndroidManifest.xml 파일에 permission을 추가해줍니다.

  <uses-permission android:name="android.permission.INTERNET"/>
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>


WifiConnectivityMonitor.kt

import android.annotation.TargetApi
import android.arch.lifecycle.LiveData
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.CONNECTIVITY_SERVICE
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkInfo
import android.net.NetworkRequest
import android.os.Build

class WifiConnectivityMonitor(private val ctx: Context) : LiveData<Boolean>() {

    private var connectivityManager: ConnectivityManager = ctx.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager

    private lateinit var connectivityManagerCallback: ConnectivityManager.NetworkCallback

    override fun onActive() {
        super.onActive()
        updateConnection()
        when {
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ->
                connectivityManager.registerDefaultNetworkCallback(getConnectivityManagerCallback())
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP -> lollipopNetworkAvailableRequest()
            else -> {
                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
                    ctx.registerReceiver(networkReceiver, IntentFilter("android.net.conn.CONNECTIVITY_CHANGE"))
                }
            }
        }
    }

    override fun onInactive() {
        super.onInactive()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            connectivityManager.unregisterNetworkCallback(connectivityManagerCallback)
        } else {
            ctx.unregisterReceiver(networkReceiver)
        }
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private fun lollipopNetworkAvailableRequest() {
        val builder = NetworkRequest.Builder()
                .addTransportType(android.net.NetworkCapabilities.TRANSPORT_CELLULAR)
                .addTransportType(android.net.NetworkCapabilities.TRANSPORT_WIFI)
        connectivityManager.registerNetworkCallback(builder.build(), getConnectivityManagerCallback())
    }

    private fun getConnectivityManagerCallback(): ConnectivityManager.NetworkCallback {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {

            connectivityManagerCallback = object : ConnectivityManager.NetworkCallback() {
                override fun onAvailable(network: Network?) {
                    postValue(true)
                }

                override fun onLost(network: Network?) {
                    postValue(false)
                }
            }
            return connectivityManagerCallback
        } else {
            throw IllegalAccessError("Should not happened")
        }
    }

    private val networkReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            updateConnection()
        }
    }

    private fun updateConnection() {
        val activeNetwork: NetworkInfo? = connectivityManager.activeNetworkInfo
        postValue(activeNetwork?.isConnected == true)
    }
}


WifiConnectivityMonitor 클래스 사용 예시

private fun initWifiMonitor() {
    val connectivityMonitor = WifiConnectivityMonitor(this)
        connectivityMonitor.observe(this, Observer { isConnected ->
            isConnected?.let {
                if (it) {
                    ip_address.text = getWifiIPAddress()
                    port.visibility = View.VISIBLE
                } else {
                    ip_address.text = getString(R.string.wifi_not_available)
                    port.visibility = View.GONE
                }
            }
        })
}

private fun getWifiIPAddress(): String {
    val wm = getSystemService(Service.WIFI_SERVICE) as WifiManager
    return Formatter.formatIpAddress(wm.connectionInfo.ipAddress)
}