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

지난 강의에서 중력센서를 사용해 기기의 기울기 각도를 확인하는 법을 알아보았는데요, 이 기울기 각도에 따라 공기방울이 움직이게 하면 될 것 같습니다.
기존 코드 확인
그럼 중력센서로부터 기울기 각도를 계산하는 기존 코드를 확인하겠습니다.
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 클래스를 만듭니다 이 클래스는 각도를 전달받은 뒤 수평계 화면을 표시합니다. 수평계 그래픽은 Paint와 Canvas를 써서 화면에 직접 그리도록 하겠습니다.
우선은 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)
}
}
}
|
이렇게 해서 안드로이드로 수평계를 만드는 법에 대해 알아보았습니다.