수평계 앱 만들기

이번 포스팅에서는 수평계 앱을 만드는 방법에 대해 알아보도록 하겠습니다.

수평계는 다음과 같이 액체 속에 공기방울이 들어가 있는 것으로, 공기방울이 중력의 반대방향으로 움직이기 때문에 기울기 정도를 직관적으로 확인할 수 있습니다. 여기서는 이 수평계를 구현하는 앱을 만들어 볼 겁니다.

지난 강의에서 중력센서를 사용해 기기의 기울기 각도를 확인하는 법을 알아보았는데요, 이 기울기 각도에 따라 공기방울이 움직이게 하면 될 것 같습니다.

기존 코드 확인

그럼 중력센서로부터 기울기 각도를 계산하는 기존 코드를 확인하겠습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class MainActivity : AppCompatActivity(), SensorEventListener {
    private val binding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }
    private val sensorManager by lazy {
        getSystemService(Context.SENSOR_SERVICE) as SensorManager
    }
    private lateinit var event: SensorEvent

    @SuppressLint("SourceLockedOrientationActivity")
    override fun onCreate(savedInstanceState: Bundle?) {
        // 세로모드 고정
        requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT

        super.onCreate(savedInstanceState)
        setContentView(binding.root)
    }

    override fun onResume() {
        super.onResume()
        sensorManager.registerListener(
            this,
            sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY),
            SensorManager.SENSOR_DELAY_FASTEST
        )
    }

    override fun onPause() {
        super.onPause()
        sensorManager.unregisterListener(this)
    }

    override fun onSensorChanged(event: SensorEvent?) {
        event?.let {
            this.event = event
            val x = event.values[0]
            val y = event.values[1]
            val z = event.values[2]
            val r = sqrt(x.pow(2) + y.pow(2) + z.pow(2))

            Log.d("MainActivity", "onSensorChanged: x: $x, y: $y, z: $z, R: $r")
            val xAngle = (90 - acos(x / r) * 180 / PI).toFloat()
            val yAngle = (90 - acos(y / r) * 180 / PI).toFloat()
        }
    }

    override fun onAccuracyChanged(p0: Sensor?, p1: Int) {}
}

TiltView 작성

여기서는 각도를 전달받는 TiltView라는 View 클래스를 만듭니다 이 클래스는 각도를 전달받은 뒤 수평계 화면을 표시합니다. 수평계 그래픽은 PaintCanvas를 써서 화면에 직접 그리도록 하겠습니다.

우선은 onSensorEvent 에서 각도를 전달받은 뒤 xPos, yPos로 변환합니다. 그리고 함수 마지막에 invalidate를 실행시켜 화면을 다시 그리도록 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class TiltView(context: Context, attrs: AttributeSet) : View(context, attrs) {

    private var xPos = 0f
    private var yPos = 0f

    fun onSensorEvent(xAngle: Float, yAngle: Float) {
        // 각도를 진폭으로 스케일
        // 정방향으로 세웠을 때 y가 -값이 나오므로 계산을 4/4분면에서 직관적으로 하기 위해 +로 변환
        xPos = xAngle * scale
        yPos = yAngle * -scale

        // 뷰를 다시 그리기 위해 onDraw를 호출
        invalidate()
    }
}

다음은 화면 드로잉에 사용할 페인트 객체를 만들어줍니다. 여기서는 굵은 선, 얇은 선, 흰색 채우기, 녹색 채우기, 버블 채우기용 객체를 준비했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class TiltView(context: Context, attrs: AttributeSet) : View(context, attrs) {

    // 드로잉에 사용할 Paint 객체
    private val thickStroke = Paint()
    private val thinStroke = Paint()
    private val whiteFill = Paint()
    private val greenFill = Paint()
    private val bubbleFill = Paint()

    init {
        // 페인트 객체 생성
        thickStroke.style = Paint.Style.STROKE
        thickStroke.strokeWidth = 5f

        thinStroke.style = Paint.Style.STROKE
        thinStroke.strokeWidth = 3f

        whiteFill.style = Paint.Style.FILL
        whiteFill.color = Color.WHITE

        greenFill.style = Paint.Style.FILL
        greenFill.color = Color.rgb(190, 220, 10)

        bubbleFill.style = Paint.Style.FILL
        bubbleFill.color = Color.rgb(230, 255, 200)
        bubbleFill.alpha = 200
    }
}

다음은 화면 작성에 필요한 각종 상수값을 준비해줍니다. 우선 onDraw 호출 전에 화면의 높이와 너비를 알 수 있는 onSizeChanged 내부에서 화면 크기를 확인합니다. 그리고 상단, 좌측 레벨, 원 수평계의 중심을 결정해 주고요, 버블의 크기와 동작 반경을 결정해 줍니다. 그리고 사각 레벨로 사용할 사각형도 정의해 줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class TiltView(context: Context, attrs: AttributeSet) : View(context, attrs) {

    // 스크린사이즈용 변수
    private var screenW = 0
    private var screenH = 0
    private var leftLevelCenterX = 0f
    private var upperLevelCenterY = 0f
    private var circleCenterX = 0f
    private var circleCenterY = 0f
    private var circleInnerR = 0f
    private var circleOuterR = 0f
    private var bubbleR = 0f
    private var bubbleBoundary = 0f
    private val squareRound = 10f

    // 레벨용 변수
    private lateinit var upperLevelOut: RectF
    private lateinit var upperLevelIn: RectF
    private lateinit var leftLevelOut: RectF
    private lateinit var leftLevelIn: RectF

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        screenW = w
        screenH = h

        // 상수값 결정
        leftLevelCenterX = (((screenW * 0.15f - screenW * 0.05f) / 2 + screenW * 0.05f))
        upperLevelCenterY = (((screenW * 0.15f - screenW * 0.05f) / 2 + screenW * 0.05f))
        circleCenterX = (((screenW * 0.95f - screenW * 0.25f) / 2 + screenW * 0.25f))
        circleCenterY = (((screenW * 0.95f - screenW * 0.25f) / 2 + screenW * 0.25f))
        circleOuterR = (((screenW * 0.95f - screenW * 0.25f) / 2))
        circleInnerR = ((circleOuterR * 0.95f))
        bubbleR = ((screenW * 0.03f))
        bubbleBoundary = circleInnerR - bubbleR

        upperLevelOut =
            RectF((screenW * 0.25f), (screenW * 0.05f), (screenW * 0.95f), (screenW * 0.15f))
        upperLevelIn =
            RectF((screenW * 0.26f), (screenW * 0.06f), (screenW * 0.94f), (screenW * 0.14f))
        leftLevelOut =
            RectF((screenW * 0.05f), (screenW * 0.25f), (screenW * 0.15f), (screenW * 0.95f))
        leftLevelIn =
            RectF((screenW * 0.06f), (screenW * 0.26f), (screenW * 0.14f), (screenW * 0.94f))
    }
}

다음은 onDraw로 화면을 그려주는데요, 코드가 길기 때문에 몇 개 모듈로 나누어 설명하겠습니다.

1
2
3
4
5
6
7
8
9
class TiltView(context: Context, attrs: AttributeSet) : View(context, attrs) {
    ...
    override fun onDraw(canvas: Canvas?) {
        drawBackground(canvas)
        findBubblePosition()
        drawBubble(canvas)
        drawBubbleGuide(canvas)
    }
}

우선은 배경색과 수평기의 형체를 그려주는 drawBackground 메소드를 작성합니다. drawColor로 배경색을 칠해주고 drawRoundRect로 상단과 좌단 레벨을 그려줍니다. 마지막으로 drawCircle로 중앙의 수평계를 그려줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private fun drawBackground(canvas: Canvas?) {
    // 배경 드로잉
    canvas?.apply {
        drawColor(Color.rgb(139, 69, 19))  // 배경색

        // 상단 레벨
        drawRoundRect(upperLevelOut, squareRound, squareRound, thickStroke)
        drawRoundRect(upperLevelOut, squareRound, squareRound, whiteFill)
        drawRoundRect(upperLevelIn, squareRound, squareRound, thinStroke)
        drawRoundRect(upperLevelIn, squareRound, squareRound, greenFill)

        // 좌측 레벨
        drawRoundRect(leftLevelOut, squareRound, squareRound, thickStroke)
        drawRoundRect(leftLevelOut, squareRound, squareRound, whiteFill)
        drawRoundRect(leftLevelIn, squareRound, squareRound, thinStroke)
        drawRoundRect(leftLevelIn, squareRound, squareRound, greenFill)

        // 중앙 원
        drawCircle(circleCenterX, circleCenterY, circleOuterR, thickStroke)
        drawCircle(circleCenterX, circleCenterY, circleOuterR, whiteFill)
        drawCircle(circleCenterX, circleCenterY, circleInnerR, thinStroke)
        drawCircle(circleCenterX, circleCenterY, circleInnerR, greenFill)
    }
}

다음은 원형 수평계를 4/4분면이라 가정하고 센서에서 전달받은 각도값을 4/4분면 위의 좌표로 변환하는 findBubblePosition 메소드를 작성합니다. 여기서 버블의 위치는 원형 수평계의 중심에 대해 각도값을 더한 만큼을 좌표값으로 하였습니다. 이 때 버블이 원형 수평계 밖으로 벗어날 경우, 몇 번째 사분면에 위치하는지를 감지해 원의 경계 이상으로 나가지 않도록 좌표값을 다시 계산해주도록 했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private fun findBubblePosition() {
    // 중심 버블 위치보정
    bubbleXpos = circleCenterX + xPos
    bubbleYpos = circleCenterY + yPos

    if (xPos > 0 && yPos < 0) {  // 1사분면
        if ((xPos).pow(2) + (yPos).pow(2) >= bubbleBoundary.pow(2)) {
            newXpos = (bubbleBoundary * cos(atan(yPos / xPos)))
            newYpos = (bubbleBoundary * sin(atan(yPos / xPos)))

            bubbleXpos = circleCenterX + newXpos
            bubbleYpos = circleCenterY + newYpos
        }
    } else if (xPos < 0 && yPos < 0) {  // 2사분면
        if ((xPos).pow(2) + (yPos).pow(2) >= bubbleBoundary.pow(2)) {
            newXpos = -(bubbleBoundary * cos(atan(yPos / xPos)))
            newYpos = -(bubbleBoundary * sin(atan(yPos / xPos)))

            bubbleXpos = circleCenterX + newXpos
            bubbleYpos = circleCenterY + newYpos
        }
    } else if (xPos < 0 && yPos > 0) {  // 3사분면
        if ((xPos).pow(2) + (yPos).pow(2) >= bubbleBoundary.pow(2)) {
            newXpos = -(bubbleBoundary * cos(atan(yPos / xPos)))
            newYpos = -(bubbleBoundary * sin(atan(yPos / xPos)))

            bubbleXpos = circleCenterX + newXpos
            bubbleYpos = circleCenterY + newYpos
        }
    } else if (xPos > 0 && yPos > 0) {  // 4사분면
        if ((xPos).pow(2) + (yPos).pow(2) >= bubbleBoundary.pow(2)) {
            newXpos = (bubbleBoundary * cos(atan(yPos / xPos)))
            newYpos = (bubbleBoundary * sin(atan(yPos / xPos)))

            bubbleXpos = circleCenterX + newXpos
            bubbleYpos = circleCenterY + newYpos
        }
    }
}

findBubblePosition으로 버블의 위치를 정했으니 drawCircle로 버블을 화면에 그려줍니다. 이 때 상단 레벨속의 버블은 x축으로만 움직일 수 있으니 y값을 고정하고, 좌단 레벨속의 버블은 동일하게 x값을 고정하면 되겠죠.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
private fun drawBubble(canvas: Canvas?) {
    // 버블 드로잉
    canvas?.apply {
        // 상단 (yPos가 고정)
        drawCircle(bubbleXpos, upperLevelCenterY, bubbleR, thinStroke)
        drawCircle(bubbleXpos, upperLevelCenterY, bubbleR, bubbleFill)

        // 좌측 (xPos가 고정)
        drawCircle(leftLevelCenterX, bubbleYpos, bubbleR, thinStroke)
        drawCircle(leftLevelCenterX, bubbleYpos, bubbleR, bubbleFill)

        // 중앙
        drawCircle(bubbleXpos, bubbleYpos, bubbleR, thinStroke)
        drawCircle(bubbleXpos, bubbleYpos, bubbleR, bubbleFill)
    }
}

마지막으로 수평계 위에 가이드를 그려줍니다. Canvas는 그리는 순서대로 z축 깊이가 결정되기 때문에 가장 위의 레이어로에 표시되어야 하는 가이드를 가장 나중에 그린 것입니다. drawCircle로 원 가이드를 그리고 drawLine으로 십자 가이드를 그려줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private fun drawBubbleGuide(canvas: Canvas?) {
    // 가이드
    canvas?.apply {
        // 원 가이드 중앙
        drawCircle(circleCenterX, circleCenterY, bubbleR, thinStroke)
        // 원 가이드 상단
        drawCircle(circleCenterX, (screenW * 0.10f), bubbleR, thinStroke)
        // 원 가이드 좌측
        drawCircle((screenW * 0.10f), circleCenterY, bubbleR, thinStroke)

        // 십자가이드 상단
        drawLine(
            (screenW * 0.26f),
            (screenW * 0.10f),
            (screenW * 0.94f),
            (screenW * 0.10f),
            thinStroke
        )
        drawLine(
            circleCenterX,
            (screenW * 0.06f),
            circleCenterX,
            (screenW * 0.14f),
            thinStroke
        )
        // 십자가이드 좌측
        drawLine(
            (screenW * 0.10f),
            (screenW * 0.26f),
            (screenW * 0.10f),
            (screenW * 0.94f),
            thinStroke
        )
        drawLine(
            (screenW * 0.06f),
            circleCenterY,
            (screenW * 0.14f),
            circleCenterY,
            thinStroke
        )
        // 십자가이드 중앙
        drawLine(
            circleCenterX,
            (screenW * 0.26f),
            circleCenterX,
            (screenW * 0.94f),
            thinStroke
        )
        drawLine(
            (screenW * 0.26f),
            circleCenterY,
            (screenW * 0.94f),
            circleCenterY,
            thinStroke
        )
    }
}

화면 작성

다음은 화면을 구성합니다. TiltView를 추가했고 x, y 각도값을 표시하는 TextView를 두 개 추가한 단순한 화면입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.example.tiltcalculator.TiltView
        android:id="@+id/tiltView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/linearLayout"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <LinearLayout
        android:id="@+id/linearLayout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent">

        <TextView
            android:id="@+id/tv_xangle"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:background="@android:drawable/editbox_background_normal"
            android:gravity="center"
            android:hint="xAngle"
            android:padding="5dp"
            android:textSize="24sp" />

        <TextView
            android:id="@+id/tv_yangle"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:background="@android:drawable/editbox_background_normal"
            android:gravity="center"
            android:hint="yAngle"
            android:padding="5dp"
            android:textSize="24sp" />
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity

이제 MainActivity에서 획득한 x, y 각도값을 TiltView에 전달함으로써 매 센서 이벤트마다 화면 드로잉을 수행하도록 합니다. 이 때 각 TextView에는 x, y축 각도를 표시하도록 하고 그 값이 0이 되었을 때는 수평상태가 되었다고 표시하도록 했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
override fun onSensorChanged(event: SensorEvent?) {
    event?.let {
        ...
        binding.tiltView.onSensorEvent(xAngle = xAngle, yAngle = yAngle)

        if (round(xAngle * 10) / 10 == 0.0f) {
            binding.tvXangle.text = "Horizontally Aligned"
        } else {
            binding.tvXangle.text = String.format("x: %.1f\u00B0", xAngle)
        }

        if (round(yAngle * 10) / 10 == 0.0f) {
            binding.tvYangle.text = "Vertically Aligned"
        } else {
            binding.tvYangle.text = String.format("y: %.1f\u00B0", yAngle)
        }
    }
}

이렇게 해서 안드로이드로 수평계를 만드는 법에 대해 알아보았습니다.

Built with Hugo
Theme Stack designed by Jimmy