코틀린 확장 함수

|

코틀린은 확장 함수를 지원합니다.

Java의 경우 기존에 만들어진 클래스에 새로운 메소드를 추가하려면 해당 클래스를 상속하는 새로운 클래스를 만들어야 하는데, 코틀린에서는 확장 함수(Extension Function)를 이용해서 상속 없이 기존 클래스에 새로운 함수를 추가할 수 있습니다.

이 때 확장 함수를 추가할 대상 클래스는 리시버 타입(Receiver Type)이라고 합니다.

예를 들어 다음과 같은 방법으로 확장 함수를 추가할 수 있습니다.

fun main(args: Array<String>) {
    println("snowdeer".hello())
}

fun String.hello() : String {
    return "$this, hello"
}

확장 함수의 사용 방법은 클래스 내 정의된 메소드와 동일하게 점(.)을 찍고 호출할 수 있지만, 실제로는 클래스 외부에서 추가된 함수이기 때문에 함수 내에서는 privateprotected로 선언된 변수나 메소드에 접근할 수 없습니다.

Scale 및 Gesture Handler

|

화면의 TouchEvent에서 멀티 터치를 이용한 Scale이나 Gesture를 인식하는 Handler 코드 예제입니다.

package com.snowdeer.utils

import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector

interface OnScaleGestureEventListener {
    fun onSingleTab(x: Float, y: Float)
    fun onDoubleTab(x: Float, y: Float)
    fun onLongPress(x: Float, y: Float)
    fun onScaleChanged(scaleFactor: Float)
    fun onOffsetChanged(offsetX: Float, offsetY: Float)
}

class ScaleGestureHandler(ctx: Context) {

    private var scaleFactor = 1.0F
    private var offsetX = 0.0F
    private var offsetY = 0.0F
    private var scaledOffsetX = 0.0F
    private var scaledOffsetY = 0.0F
    private var focusX = 0.0F
    private var focusY = 0.0F

    private val scaleGestureDetector: ScaleGestureDetector
    private val gestureDetector: GestureDetector
    var onScaleGestureEventListener: OnScaleGestureEventListener? = null

    init {
        scaleGestureDetector = ScaleGestureDetector(ctx, ScaleListener())
        gestureDetector = GestureDetector(ctx, GestureListener())
    }

    fun handleTouchEvent(event: MotionEvent): Boolean {
        scaleGestureDetector.onTouchEvent(event)

        if (gestureDetector.onTouchEvent(event)) {
            return true
        }

        return false
    }

    fun reset() {
        scaleFactor = 1.0F
        offsetX = 0F
        offsetY = 0F
        scaledOffsetX = 0F
        scaledOffsetY = 0F
        focusX = 0.0F
        focusY = 0.0F
    }

    private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            scaleFactor *= detector.scaleFactor
            scaleFactor = Math.max(0.8f, Math.min(scaleFactor, 3.0f))

            focusX = detector.focusX
            focusY = detector.focusY

            onScaleGestureEventListener?.onScaleChanged(scaleFactor)

            offsetX = (scaledOffsetX - focusX) * scaleFactor + focusX
            offsetY = (scaledOffsetY - focusY) * scaleFactor + focusY

            onScaleGestureEventListener?.onOffsetChanged(offsetX, offsetY)

            return true
        }
    }

    private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
        override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
            onScaleGestureEventListener?.onSingleTab(e.x, e.y)
            return true
        }

        override fun onDoubleTap(e: MotionEvent): Boolean {
            onScaleGestureEventListener?.onDoubleTab(e.x, e.y)
            return true
        }

        override fun onLongPress(e: MotionEvent) {
            onScaleGestureEventListener?.onLongPress(e.x, e.y)
        }

        override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
            scaledOffsetX -= distanceX / scaleFactor
            scaledOffsetY -= distanceY / scaleFactor

            offsetX = (scaledOffsetX - focusX) * scaleFactor + focusX
            offsetY = (scaledOffsetY - focusY) * scaleFactor + focusY

            onScaleGestureEventListener?.onOffsetChanged(offsetX, offsetY)

            return true
        }
    }
}

Runtime Permission (Kotlin 버전)

|

기존에 Java 버전의 Runtime Permission을 포스팅 했었지만 이번에는 Kotlin 버전으로 포스팅합니다.

private const val PERMISSION_REQUEST_CODE = 1231

class MainActivity : AppCompatActivity() {

    private val permissions = arrayOf(
            Manifest.permission.WRITE_EXTERNAL_STORAGE
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_main)

        if (!checkPermissions(permissions)) {
            requestPermissions(permissions, PERMISSION_REQUEST_CODE)
        }
    }

    private fun checkPermissions(permissions: Array<String>): Boolean {
        for (permission in permissions) {
            if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
                return false
            }
        }
        return true
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        when (requestCode) {
            PERMISSION_REQUEST_CODE -> {
                if ((grantResults.isNotEmpty()) && (grantResults[0] == PackageManager.PERMISSION_GRANTED)) {

                } else {
                    Toast.makeText(applicationContext, "Permission is not granted.", Toast.LENGTH_SHORT).show()
                }
            }

        }
    }
}

build.gradle 에서 변수 사용하기

|

gradle에서 변수를 사용하는 방법입니다.

예를 들어 특정 라이브러리의 버전을 변수로 지정할 수 있습니다.

build.gradle (Project)

buildscript {
    ext.kotlin_version = '1.3.41'
    ext.snowlib_version = 'latest.integration'

    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.4.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}


build.gradle (Module)

그리고 아래 예제와 같은 방법으로 위에서 선언한 snowlib_version이라는 변수를 사용할 수 있습니다.

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation project(':common_library')
    implementation 'com.google.code.gson:gson:2.8.5'

    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    implementation 'com.android.support:design:28.0.0'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

    implementation group: 'snowdeer.message.utils', name: 'snowdeer-message-util', version: "$snowlib_version", changing: true
    implementation group: 'snowdeer.actiontool.library', name: 'snowdeer-actiontool-library', version: "$snowlib_version", changing: true

    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

경로를 따라 그리는 Path Animation

|

먼저 View를 상속받는 캔버스(Canvas)를 가진 클래스를 하나 구현합니다. 그리고 setPercentage 메소드를 만들어줍니다. 클래스 외부에서 애니메이션을 동작시킬 때 필요한 progress 관련 메소드입니다.

class PathView(context: Context, attrs: AttributeSet) : View(context, attrs) {

    companion object {
        private const val CORNER_ROUND = 40F
    }

    private val wayPointList = ArrayList<PointF>()

    private val paint = Paint()

    private var progress = 0F
    private var pathLength = 0F

    init {
        paint.apply {
            color = context.getColor(R.color.path_color)
            style = Paint.Style.STROKE
            isAntiAlias = true
            strokeWidth = 8.0F
            strokeCap = Paint.Cap.ROUND
            strokeJoin = Paint.Join.ROUND
        }

        initDummyData()
    }

    private fun initDummyData() {
        wayPointList.add(PointF(190F, 1715F))
        wayPointList.add(PointF(270F, 1715F))
        wayPointList.add(PointF(270F, 650F))
        wayPointList.add(PointF(460F, 650F))
        wayPointList.add(PointF(460F, 500F))
    }

    fun setPath(pointList: ArrayList<Point>) {
        wayPointList.clear()

        for (p in pointList) {
            val x = MapCoordinateConverter.getCanvasXFromWorldX(p.x).toFloat()
            val y = MapCoordinateConverter.getCanvasXFromWorldX(p.y).toFloat()

            wayPointList.add(PointF(x, y))
        }

        invalidate()
    }

    fun setPercentage(percentage: Float) {
        if (percentage < 0.0f || percentage > 1.0f) {
            throw IllegalArgumentException("setPercentage not between 0.0f and 1.0f")
        }

        progress = percentage
        invalidate()
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        drawPath(canvas)
    }

    private fun drawPath(canvas: Canvas?) {
        val p = createPath()

        val measure = PathMeasure(p, false)
        pathLength = measure.length

        val total = pathLength - pathLength * progress
        val pathEffect = DashPathEffect(floatArrayOf(pathLength, pathLength), total)

        val cornerPathEffect = CornerPathEffect(CORNER_ROUND)
        paint.pathEffect = ComposePathEffect(cornerPathEffect, pathEffect)

        canvas?.drawPath(p, paint)
    }

    private fun createPath(): Path {
        val p = Path()

        if(wayPointList.size > 0) {
            p.moveTo(wayPointList[0].x, wayPointList[0].y)
            for (pf in wayPointList) {
                p.lineTo(pf.x, pf.y)
            }
        }

        return p
    }

}

위에서 만든 클래스를 외부에서 사용할 때는 다음과 같이 작성하면 됩니다.

class MapView(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) {

    private val pathView = PathView(context, attrs)

    init {

        val layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT)

        addView(pathView, layoutParams)
    }

    fun start() {
        playPathAnimation(pathView)
    }

    fun setPath(pointList: ArrayList<Point>) {
        pathView.setPath(pointList)
    }

    private fun playPathAnimation(target: View) {
        val anim = ObjectAnimator.ofFloat(target, "percentage", 0.0f, 1.0f)

        anim.duration = 3000
        anim.interpolator = LinearInterpolator()
        anim.start()
    }
}