[Android] 화면 터치 중 Swiping을 이용한 View 전환

화면 터치가 가능한 모바일 단말기에서 터치를 통한 UI의 조작은 매우 효과적입니다. 이러한 터치 기반의 UI의 활용에 대해 자연스러운 사용은 사용자에게 프로그램의 친밀도를 높여줍니다. 화면 터치에 대한 조작 중 Swiping은 사용자가 화면을 스치듯이 상하좌우로 쓸어넘기는 행위입니다. 이러한 Swiping 중 좌우에 대한 이벤트를 처리하기 위한 클래스는 다음과 같습니다.

package geoservice.nexgen

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

abstract class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
    companion object {
        private const val SWIPE_DISTANCE_THRESHOLD = 100
        private const val SWIPE_VELOCITY_THRESHOLD = 100
    }

    private val gestureDetector: GestureDetector

    abstract fun onSwipeLeft()
    abstract fun onSwipeRight()

    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        return gestureDetector.onTouchEvent(event)
    }

    private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            return true
        }

        override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
            val distanceX = e2.x - e1.x
            val distanceY = e2.y - e1.y
            if (Math.abs(distanceX) > Math.abs(distanceY) 
                    && Math.abs(distanceX) > SWIPE_DISTANCE_THRESHOLD 
                    && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) {
                if (distanceX > 0) onSwipeRight() else onSwipeLeft()
                return true
            }
            return false
        }
    }

    init {
        gestureDetector = GestureDetector(context, GestureListener())
    }
}

위 클래스를 실제 View에 적용하는 코드의 예는 다음과 같습니다.

llListScroll.setOnTouchListener(object: OnSwipeTouchListener(context) {
    override fun onSwipeLeft() { btnNext.performClick() }
    override fun onSwipeRight() { btnPrevious.performClick() }
})

실제 위의 코드는 모바일 기반의 GIS 솔루션인 Mobile NexGen에 반영된 코드인데요. 위의 코드와 연관된 기능에 대한 시연 영상은 아래와 같습니다.

예제로 정리하는 코틀린의 코루틴(Kotlin Coroutine)

코루틴은 스레드와 기능적으로 같지만, 스레드에 비교하면 좀더 가볍고 유연하며 한단계 더 진화된 병렬 프로그래밍을 위한 기술입니다. 하나의 스레드 내에서 여러개의 코루틴이 실행되는 개념인데, 아래의 코드는 동일한 기능을 스레드와 코루틴으로 각각 구현한 코드의 예시입니다.

Thread(Runnable {
    for(i in 1..10) {
        Thread.sleep(1000L)
        print("I'm working in Thread.")
    }
}).start()

GlobalScope.launch() {
    repeat(10) {
        delay(1000L)
        print("I'm working in Coroutine.")
    }
}

아래는 코루틴에 대해서 초점을 맞춰서 가장 간단한 코루틴의 예제입니다.

print("Start Main Thread")

GlobalScope.launch {
    delay(3000)
    print("in Coroutine ...")
}

print("End Main Thread")

코루틴은 GlobalScope.launch로 정의되며 { .. } 으로 묶은 코드가 비동기적으로 실행됩니다. 실행 결과는 다음과 같습니다.

V: Start Main Thread
V: End Main Thread
V: in Coroutine ...

다음은 비동기적으로 실행된 코루틴이 완료되어 그 결과를 반환받는 예제입니다.

GlobalScope.launch {
    launch {
        print("At Here!")
    }

    val value: Int = async {
        var total = 0
        for (i in 1..10) total += i
        total
    }.await()

    print("$value")
}

결과는 다음과 같습니다.

V: At Here!
V: 55

다음 코드 역시 비동기적으로 실행된 코루틴의 완료를 기다리고 그 결과를 반환받아 출력하는 예제입니다.

GlobalScope.launch {
    val x = doSomething()
    print("done something, $x")
}

private suspend fun doSomething():Int {
    val value: Int = GlobalScope.async(Dispatchers.IO) {
        var total = 0
        for (i in 1..10) total += i
        print("do something in a suspend method: $total")
        total
    }.await()

    return value
}

비동기적으로 실행되는 코루틴을 별도의 함수로 분리했는데, 코루틴 내부에서 실행되는 함수는 suspend로 지정해야 합니다. 위의 코드의 결과는 다음과 같습니다.

V: do something in a suspend method: 55
V: done something, 55

이번에는 2개의 코루틴을 실행하고 이 2개의 결과를 받아 출력하는 예제입니다.

print("Start...")
GlobalScope.launch(Dispatchers.Main) {
    val job1 = async(Dispatchers.IO) {
        var total = 0
        for (i in 1..10) {
            total += i
            delay(100)
        }
        print("job1")
        total
    }

    val job2 = async(Dispatchers.Main) {
        var total = 0
        for (i in 1..10) {
            delay(100)
            total += i
        }
        print("job2")
        total
    }

    val result1 = job1.await()
    val result2 = job2.await()

    print("results are $result1 and $result2")
}
print("End.")

위의 코드에서 볼 수 있는 Dispatchers.Main, Dispatchers.IO는 각각 UI 변경 등을 처리하는 메인 스레드 그리고 입출력 연산을 처리하기에 적합한 IO 스레드를 의미하며, 코루틴들은 이처럼 지정된 스레드 내에서 실행됩니다. 위 코드의 결과는 다음과 같습니다.

V: Start...
V: End.
V: job1
V: job2
V: results are 55 and 55

다음은 코루틴이 완료를 기다리기 위한 await 호출을 사용하지 않는 또다른 방법입니다.

GlobalScope.launch(Dispatchers.IO) {
    val v = withContext(Dispatchers.Main) {
        var total = 0
        for (i in 1..10) {
            delay(100)
            total += i
        }

        total
    }

    print("result: $v")
    print("Do something in IO thread")
}

withContext를 써서 새로운 코루틴을 다른 스레드에서 동기적으로 실행하도록 하는 코드입니다. 결과는 다음과 같습니다.

V: result: 55
V: Do something in IO thread

launch는 Job 객체를 반환하는데, 이를 통해 다음 예제처럼 코루틴을 중간에 중단시킬 수 있습니다.

print("start..")

val job = GlobalScope.launch() {
    repeat(10) {
        delay(1000L)
        print("I'm working.")
    }
}

runBlocking {
    delay(3000L)
    job.cancel()
}

print("stop")

실행 결과는 다음과 같습니다.

V: start..
V: I'm working.
V: I'm working.
V: stop

이번에는 Job 객체를 통해 코루틴이 완전이 종료될때까지 기다리는 예제입니다.

print("start..")

val job = GlobalScope.launch() {
    repeat(10) {
        delay(1000L)
        print("I'm working.")
    }
}

runBlocking {
    job.join()
}

print("stop")

결과는 다음과 같습니다.

V: start..
V: I'm working.
V: I'm working.
V: I'm working.
V: I'm working.
V: I'm working.
V: I'm working.
V: I'm working.
V: I'm working.
V: I'm working.
V: I'm working.
V: stop

코루틴은 정해진 시간이 되면 코루틴의 완료되지 못할지라도 중지하게 할 수 있는데, 아래의 코드가 바로 그 예입니다.

print("start")

val job = GlobalScope.launch {
    withTimeout(4000L) {
        repeat(10) {
            delay(1000L)
            print("I'm working.")
        }
    }
}

print("end")

결과는 다음과 같습니다.

V: start
V: end
V: I'm working.
V: I'm working.
V: I'm working.

코루틴은 채널(Channel)이라는 개념을 통해 코루틴에서 생성한 데이터를 또 다른 코루틴에게 전달할 수 있습니다. 아래의 코드는 코루틴에서 1~5까지의 정수에 대한 제곱값을 생성하면 생성된 정수 4개를 또 다른 코루틴에서 받아 출력하는 예입니다.

runBlocking {
    print("start")

    val channel = Channel<Int>()

    launch {
        for (x in 1..5) {
            channel.send(x * x)
        }
    }

    repeat(5) {
        val v = channel.receive()
        print("$v")
    }

    print("end")
}

결과는 다음과 같습니다.

V: start
V: 1
V: 4
V: 9
V: 16
V: 25
V: end

데이터를 생성하는 쪽이나 받는 쪽에서는 얼마나 많은 데이터를 생성할지 또는 받을지를 예측할 수 없는 경우가 대부분입니다. 데이터를 생성하는 쪽에서 채널의 close 함수를 호출하면 받는쪽에서 더 이상 데이터가 없다는 것을 인지하게 되는데, 아래는 이에 대한 코드 예입니다.

runBlocking {
    print("start")

    val channel = Channel<Int>()

    launch {
        for(x in 1..5) channel.send(x*x)
        channel.close()
    }

    for(y in channel) print("$y")

    print("end")
}

결과는 다음과 같습니다.

V: start
V: 1
V: 4
V: 9
V: 16
V: 25
V: end

다음은 데이터를 생성하는 코루틴을 함수화하여 이 함수를 통해 생성된 데이터를 처리하는 예제입니다.

runBlocking {
    print("start")

    val squares = procedureSquares()
    squares.consumeEach {
        print("$it")
    }

    print("end")
}

private fun CoroutineScope.procedureSquares(): ReceiveChannel<Int> = produce {
    for(x in 1..5) send(x*x)
}

결과는 다음과 같습니다.

V: start
V: 1
V: 4
V: 9
V: 16
V: 25
V: end

다음은 데이터를 생성하는 코루틴을 파이프라인 형태로 묶어 처리하는 것으로, 첫번째 코루틴에서 생성한 값을 또 다른 코루틴에서 받아 처리하여 또 다른 코루틴으로 전달하는 예제입니다.

runBlocking {
    print("start")

    val numbers = productNumbers()
    val squares = squares(numbers)

    for(i in 1..5) print("${squares.receive()}")

    print("end")

    coroutineContext.cancelChildren()
}

private fun CoroutineScope.productNumbers() = produce<Int> {
    var x = 1
    while(true) {
        print("send ${x} on productNumbers")
        send(x++)
        delay(100)
    }
}

private fun CoroutineScope.squares(numbers:ReceiveChannel<Int>): ReceiveChannel<Int> = produce {
    for(x in numbers) {
        print("send ${x} on squares")
        send(x*x)
    }
}

결과는 다음과 같습니다.

V: start
V: send 1 on productNumbers
V: send 1 on squares
V: 1
V: send 2 on productNumbers
V: send 2 on squares
V: 4
V: send 3 on productNumbers
V: send 3 on squares
V: 9
V: send 4 on productNumbers
V: send 4 on squares
V: 16
V: send 5 on productNumbers
V: send 5 on squares
V: 25
V: end

데이터를 생성하는 코루틴은 1개지만, 이를 원활하게 처리하기 위해 여러개의 코루틴으로 생성된 데이터를 처리할 수 있습니다. 아래는 데이터를 생성하는 코루틴 1개와 생성된 데이터를 처리하는 5개의 코루틴에 대한 예제입니다.

runBlocking {
    val producer = productNumbers()
    repeat(5) {
        launchProcessor(it, producer)

    }

    delay(1000L)
    producer.cancel()
}

fun CoroutineScope.launchProcessor(id:Int, channel: ReceiveChannel<Int>) {
    launch {
        for(msg in channel) {
            print("Processor #$id received $msg")
        }
    }
}

private fun CoroutineScope.productNumbers() = produce<Int> {
    var x = 1
    while(true) {
        print("send ${x} on productNumbers")
        send(x++)
        delay(100)
    }
}

결과는 다음과 같습니다.

V: send 1 on productNumbers
V: Processor #0 received 1
V: send 2 on productNumbers
V: Processor #0 received 2
V: send 3 on productNumbers
V: Processor #1 received 3
V: send 4 on productNumbers
V: Processor #2 received 4
V: send 5 on productNumbers
V: Processor #3 received 5
V: send 6 on productNumbers
V: Processor #4 received 6
V: send 7 on productNumbers
V: Processor #0 received 7
V: send 8 on productNumbers
V: Processor #1 received 8
V: send 9 on productNumbers
V: Processor #2 received 9
V: send 10 on productNumbers
V: Processor #3 received 10

반대로 데이터를 생성하는 코루틴은 여러개이고 처리하는 코루틴은 1개인 경우도 있습니다. 아래는 데이터를 생성하는 코루틴 2개와 생성된 데이터를 처리하는 코루틴 1개에 대한 예제입니다.

runBlocking {
    val channel = Channel<String>()
    launch {
        sendString(channel, "foo", 200L)
    }

    launch {
        sendString(channel, "BAR", 500L)
    }

    repeat(6) {
        print("${channel.receive()}")
    }

    coroutineContext.cancelChildren()
}

private suspend fun sendString(channel: SendChannel<String>, s:String, time:Long) {
    while(true) {
        delay(time)
        channel.send(s)
    }
}

결과는 다음과 같습니다.

V: foo
V: foo
V: BAR
V: foo
V: foo
V: BAR

아래의 예제는 2개의 코루틴에서 하나의 데이터에 대해 어떤 처리를 해서 주고 받는 기능에 대한 코드입니다.

print("start")
data class Ball(var hits:Int)

suspend fun player(name:String, table: Channel) {
    for(ball in table) {
        ball.hits++
        print("$name $ball")
        delay(300)
        table.send(ball)
    }
}

runBlocking {
    var table = Channel<Ball>()

    launch {
        player("ping", table)
    }

    launch {
        player("pong", table)
    }

    table.send(Ball(0))
    delay(1000)
    coroutineContext.cancelChildren()
}
print("end")

결과는 다음과 같습니다.

V: start
V: ping Ball(hits=1)
V: pong Ball(hits=2)
V: ping Ball(hits=3)
V: pong Ball(hits=4)
V: end

ViewPropertyAnimator를 이용한 개별 뷰(View) 단위 애니메이션

안드로이드에서 하나의 뷰에 대한 특정 속성값을 애니메이션화하기 위한 API인 ViewPropertyAnimator에 대한 내용입니다. 먼저 아래와 같은 레이아웃에 애니메이션을 위한 버튼 뷰를 하나 배치합니다.

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:scrollbars="none">

    <Button
        android:id="@+id/myView"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"
        android:text="Hello" />

</ScrollView>

이제 버튼을 터치할때 애니매이션이 시작될 수 있도록 다음 코드를 추가합니다.

myView.setOnClickListener {
    myView
        .animate()
        .scaleX(5.0f)
        .scaleY(5.0f)
        .alpha(0.0f)
        .translationX(400.0f)
        .translationY(400.0f)
        .rotation(360.0f)
        .setDuration(1000)
        .withStartAction {
            Toast.makeText(this, "애니메이션 시작", Toast.LENGTH_SHORT).show()
        }
        .withEndAction {
            myView.scaleX = 1.0f;
            myView.scaleY = 1.0f;
            myView.alpha = 1.0f;
            myView.x = 0.0f;
            myView.y = 0.0f;
            myView.rotation = 0.0f;
            Toast.makeText(this, "완료", Toast.LENGTH_SHORT).show()
        }.start()
}

참고로 언어는 코들린입니다. 1초간 뷰의 크기를 5배로, 점점 투명하게, X와 Y의 위치를 각각 400과 400으로, z축으로 360회전 하도록 하는애니메이션입니다. 그 결과는 아래와 같습니다.

안드로이드의 Shape 형태의 Drawable

직사각형(Rectangle) 형태의 Shape Drawable 정의는 다음과 같다.

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
       android:shape="rectangle">
    <stroke android:width="10dp"
            android:color="#ffff00" />
    <solid android:color="@android:color/transparent" />
    <corners android:radius="50dp" />
</shape>

위 형태에 대한 결과는 다음과 같다.

실제 뷰의 배경(Background)에 적용할 수 있는데, 그 예는 다음과 같다.

<LinearLayout android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:background="@drawable/rectangle_shape_drawable"
              android:orientation="vertical">

...