조이스틱은 흔히 PlayStation, XBox 등의 콘솔 게임에서 캐릭터의 이동 방향을 조정하기 위해 사용된다. 그 외에도 우리 생활 주변에서 다양한 용도로 사용되고 있다. 여러 기능을 갖춘 복잡한 조이스틱들도 있지만, 조이스틱의 기본 기능은 스틱을 조작하여 2차원 평면상에서 xy 좌표값을 지정하고 이 값을 이용하여 어떤 대상을 제어하는 것이다. 그래서 RC카, 드론 등과 같이 원격에 있는 대상을 조정하는 용도로 주로 사용된다. 안드로이드 폰 역시 BT, BLE 등을 통해 다른 장치와 원격으로 연결이 가능하다. 그렇다면 안드로이드 앱으로 조이스틱 기능을 사용하여 리모트 컨트롤러(RC)로 활용할 수 있다. 하지만 안드로이드에는 기본으로 제공되는 조이스틱 위젯이 없기 때문에 필요하다면 커스텀 뷰로 만들어야 한다. 최근에 취미로 메카넘 휠 차량을 만들고 있는데, 이번 기회에 안드로이드 폰에서 BLE 통신과 조이스틱 기능을 앱으로 만들어 두면 나중에 다른 장치도 원격으로 제어할 수 있어 유용할 것 같았다. 그래서 이 글에서는 안드로이드 폰에서 동작하는 조이스틱 기능을 커스텀 뷰로 간단히 만들어 보는 과정을 소개한다.
UI, UX
구현해야 할 조이스틱이 어떻게 구성되고 동작해야 할지를 먼저 정해보자.
조이스틱은 스틱과 베이스 컴포넌트로 구성된다. GUI 적으로는 두 컴포넌트는 원형으로 표시되며 스틱이 베이스 위에서 움직여야 하므로 스틱이 베이스보다 작아야 한다. 스틱은 터치 지점으로 이동하며 베이스 영역을 벗어날 수 없다. 터치 위치를 방향(angle)과 세기(strength) 값으로 계산하여 설정된 시간 주기마다 이 뷰를 사용하는 컴포넌트로 콜백을 통해 알려준다. 만약 스프링 속성을 사용한다면 스틱을 놓았을 때 원래 위치인 중앙으로 스틱이 이동한다.
조이스틱의 동작에 대해 정의를 하였으니 이것을 기초로 하여 구현해보자.
프로젝트 생성
안드로이드 스튜디오에서 아래 과정으로 프로젝트를 생성한다.
1. File > New > New Project... 순서로 이동한다.
2. 'Phone and Tablet' 창에서 'Empty Activity' 선택 후 'Next' 버튼을 클릭한다.
3. 'Configure Your Project' 창에서 아래 예시와 같이 입력 후 'Finish' 버튼을 클릭하여 프로젝트를 생성한다.
Name : joystick
Package name : com.monotics.app.joystick
Save location : D:\temp\joystick
Language : Kotlin
Minimum SDK : API 28: Android 9.0 (Pie)
라이브러리 모듈 생성 및 추가
커스텀 뷰에 해당하는 라이브러리 모듈을 만들어서 프로젝트에 추가해보자. 라이브러리 모듈은 아래 과정을 통해 생성한다. 라이브러리 모듈 생성과정은 안드로이드 가이드에서 자세히 설명하고 있으니 참조 바란다.
1. File > New > New Module... 순서로 이동한다.
2. 'Select a Module Type' 창에서 'Android Library' 선택 후 'Next' 버튼을 클릭한다.
3. 'Android Library' 창에서 아래 예시와 같이 입력 후 'Finish' 버튼을 클릭하여 모듈을 생성한다.
Module name : joystick
Package name : com.monotics.view.joystick
Bytecode Level : 8 (slower build)
Language : Kotlin
Minimum SDK : API 28: Android 9.0 (Pie)
'joystick' 모듈이 생성되었다. 이 모듈에서 동작할 커스텀 뷰를 구현해야 하는데, 이 작업은 잠시 뒤로 미루고 우선 생성된 'joystick' 모듈을 'app' 모듈에서 사용할 수 있도록 추가하는 과정을 살펴보자. 먼저 settings.gradle 파일을 열어 ':joystick'을 추가한다.
include ':app', ':joystick'
'app' module의 build.gradle 파일을 열어 dependance 부분에 'joystick' 라이브러리를 아래와 같이 추가한다.
implementation project(":joystick")
gradle 파일이 수정되었으니 'Sync Project with Gradle Files'를 클릭하여 싱크를 한다.
joystick 모듈에 attribute 추가
외부에서 커스텀 뷰를 사용할 때 자유도를 높이기 위해 여러 가지 속성들을 정의할 수 있다. 예를 들어 스틱과 베이스의 색상을 원하는 값으로 지정할 수 있다면 GUI 적으로 자유도가 높아진다. 이렇게 커스텀 뷰 만을 위한 속성을 커스텀 속성이라 하는데, 커스텀 속성들은 이름과 타입을 정의해 주어야 외부에서 이것들에 맞게 값을 설정할 수 있다. 커스텀 속성들은 attrs.xml 리소스 파일을 통해 정의해준다. 앞서 생성한 joystick 라이브러리 모듈에는 res 디렉터리가 없으므로 직접 attrs.xml 파일을 추가해주어야 한다. 아래 과정을 통해 attrs.xml을 추가해 보자.
- 'joystick' 모듈에서 마우스 우클릭 > New > Android Resouce Directory 순서로 이동한다.
- Directory name으로 설정되어있는 기본값인 'values'를 그대로 사용하면 되므로 따로 수정 없이'OK' 버튼을 클릭한다.
- res/values 가 생성된 것을 확인한다.
- 'res/values' 디렉터리에서 마우스 우클릭 > New > Values Resource File 순서로 이동한다.
- 'New Resource File' 창의 'File name' 필드가 비어있는데, 여기에 "attrs"라고 입력 후 'OK' 버튼을 클릭한다.
- 추가된 attrs.xml 파일에 아래와 같이 커스텀 속성들을 추가한다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="Joystick">
<attr name="joystickBaseColor" format="color"/>
<attr name="joystickStickColor" format="color"/>
<attr name="joystickBaseRatio" format="fraction"/>
<attr name="joystickStickRatio" format="fraction"/>
<attr name="joystickUseSpring" format="boolean"/>
</declare-styleable>
</resources>
커스텀 속성들의 의미는 다음과 같다.
-
joystickBaseColor : 베이스의 색상을 지정한다.
-
joystickStickColor : 스틱의 색상을 지정한다.
-
joystickBaseRatio : 조이스틱 뷰 영역에서 베이스가 차지하는 비율을 지정한다.
-
joystickStickRatio : 조이스틱 뷰 영역에서 스틱이 차지하는 비율을 지정한다.
-
joystickUseSpring : 스틱을 놓았을 때 베이스의 중앙으로 스틱이 이동할지를 지정한다.
declare-styleable 태그의 name 속성으로 지정한 값(Joystick)은 커스텀 뷰에서 지정한 값을 가져오기 위해 접근하는 리소스의 이름에 prefix로 사용된다.
<declare-styleable name="Joystick">
스틱의 색상값 가져오기
getColor(R.styleable.Joystick_joystickStickColor, Color.BLUE)
조이스틱 뷰 생성
JoystickView 클래스 추가 과정은 다음과 같다.
- joystick 모듈 > java > com.monotics.view.joystick에서 마우스 우클릭한다.
- New > Kotlin Class/File으로 이동한다.
- 'New Kotlin Class/File' 창에서 'Class'를 선택하고 'JoystickView'를 입력 후 엔터키를 치면 JoystickView.kt 파일이 생성된다.
커스텀 뷰를 View 기반으로 작성할 것이기 때문에 JoystickView는 View를 상속받아야 한다. 또한 스틱의 움직임일 때마다 업데이터 정보를 등록된 리스너에게 알려줘야 한다. 그 역할을 스레드가 하기 때문에 Runnable을 구현한다. 스레드는 MotionEvent.ACTION_DOWN 터치 이벤트에서 시작하고 MotionEvent.ACTION_UP 터치 이벤트에서 멈춘다.
스틱의 좌표값 구하기
스틱이 위치할 좌표를 계산해보자. 터치 이벤트에 좌표 정보가 이미 포함되어있으므로 그 값을 그대로 사용하면 된다. 하지만 베이스의 바깥 영역을 터치하는 경우를 고려해야 한다. 이 영역에는 스틱이 도달할 수 없기 때문에, 터치 지점과 원점이 이루는 직선과 베이스의 가장자리 원주와 만나는 지점의 좌표를 구하여 거기에 스틱을 위치시키면 된다. 아래 그림을 통해 계산 식을 만들어보자.
\((x_c,y_c)\) : 베이스 원점(center)의 좌표
\((x_t,y_t)\) : 터치(touch) 지점의 좌표
\(l\) : 베이스 원점과 터치 지점을 잇는 직선의 길이
\(r\) : 베이스의 반지름
\((x_r,y_r)\) : 스틱이 도달 가능한(reachable) 지점의 좌표
베이스의 원점은 \((0,0)\)이 아니라 화면에서 어떤 지점의 좌표\((x_c, y_c)\)가 된다. 따라서 식에서 길이 값을 사용할 때 이점을 고려해야 한다. 먼저 원점에서 터치 지점까지의 길이 \(l\)을 구한다.
\[ l =\sqrt{(x_t - x_c)^2 + (y_t - y_c)^2} \]
결국 스틱이 원주 상에 위치해야 하므로 터치 지점과 반지름의 비를 사용하면 식이 쉽게 도출된다.
\[ l:r = (x_t-x_c):(x_r-x_c) \]
\[ l:r = (y_t-y_c):(y_r-y_c) \]
이 비례 식으로부터 원하는 값인 \( (x_r, y_r)\)를 구할 수 있다.
\[ x_r = { {(x_t-x_c)\cdot r \over l } + x_c} \]
\[ y_r = { {(y_t-y_c)\cdot r \over l } + y_c} \]
이 식을 코드로 옮기면 다음과 같다.
val length = sqrt((mPosX - mCenterX).pow(2) + (mPosY - mCenterY).pow(2))
if (length > baseRadius) {
// length:radius = (mPosX - mCenterX):new mPosX
// length:radius = (mPosY - mCenterY):new mPosY
mPosX = (mPosX - mCenterX) * baseRadius / length + mCenterX
mPosY = (mPosY - mCenterY) * baseRadius / length + mCenterY
}
각도 값 구하기
JoystickView를 사용하는 쪽에서는 좌표값을 받는 것보다는 각도와 세기 정보를 받는 것이 더 유용하다. 위에서 구한 좌표값을 이용하면 각도와 세기를 쉽게 구할 수 있다. 먼저 각도 값을 구해보자.
atan를 사용하면 x와 y의 비로 각도를 구할 수 있다. 여기서는 0°가되는 기준을 3시 방향으로 정하였다. 그리고 안드로이드 뷰의 좌표 시스템을 보면, x 축은 오른쪽, y 축은 아래쪽 방향으로 가면서 값이 증가한다. 이는 일반 좌표 시스템의 y 축과 반대로 되어있으니 고려해야 한다.
\[ angle = \arctan{( {y_{center}-y_{touch} \over x_{touch}-x_{center}} )} \]
이 식을 코드로 나타내면 다음과 같다.
private fun getAngle(): Int {
val xx = mPosX - mCenterX
val yy = mCenterY - mPosY
val angle = Math.toDegrees(atan2(yy, xx).toDouble()).toInt()
return if (angle < 0) angle + 360 else angle
}
세기 값 구하기
터치 지점에서 원점까지의 거리를 구하고 최댓값이 100이 되도록 세기 값을 구한다.
\[ l = \sqrt{ (x_{touch}-x_{center})^2 + (y_{touch}-y_{center})^2} \]
\[ stength = { {l \over r} \cdot 100} \]
이 식을 코드로 나타내면 다음과 같다.
private fun getStrength(): Int {
val length = sqrt((mPosX - mCenterX).pow(2) + (mPosY - mCenterY).pow(2))
return (length / baseRadius * 100).toInt()
}
구현된 JoystickView.kt의 전체 코드는 여기에서 확인할 수 있다. 그럼 app 모듈에서 JoystickView를 사용해보자.
커스텀 속성을 통한 뷰의 초기값 설정
app 모듈의 activity_main.xml layout에 <com.monotics.view.joystick.JoystickView> 태그로 커스텀 뷰를 추가한다. 여기에서는 앞에서 정의한 스틱과 베이스의 색상 그리고 스프링 기능 사용 유무의 커스텀 속성을 사용하고 있다.
<com.monotics.view.joystick.JoystickView
android:id="@+id/joystick"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginBottom="16dp"
app:joystickBaseColor="@android:color/holo_orange_light"
app:joystickStickColor="@android:color/holo_blue_dark"
app:joystickUseSpring="true"
...
MainActivity 작성
MainActivity.kt에서는 100ms 주기마다 JoystickView로부터 각도와 세기 값을 받는 리스너를 등록한다. 이 리스너가 수행될 때마다 각도 값과 세기 값을 TextView에 표시해준다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val joystickView = findViewById<JoystickView>(R.id.joystick)
val angleValueView = findViewById<TextView>(R.id.value_angle)
val strengthValueView = findViewById<TextView>(R.id.value_strength)
joystickView.setOnMoveListener({ angle, strength ->
angleValueView.text = "angle : ${angle}"; strengthValueView.text = "strength : ${strength}"
}, 100)
}
}
전체 코드는 여기에서 확인할 수 있다.
테스트
앱을 실행해서 스틱을 움직여보면 아래 그림과 같이 각도 값과 세기 값이 변하는 것을 확인할 수 있다.
전체 코드 (github) :
monotics/joystick: Joystick custom view (github.com)
참고 사이트 :
www.programmersought.com/article/74355015369/
github.com/controlwear/virtual-joystick-android
developer.android.com/training/gestures
developer.android.com/studio/projects/android-library
developer.android.com/guide/topics/ui/custom-components
'sw engineering > android' 카테고리의 다른 글
Android - Navigation Component 소개 (0) | 2021.02.24 |
---|
댓글