본문 바로가기
sw engineering/android

안드로이드 조이스틱 뷰 만들기

by monotics 2021. 3. 15.

조이스틱은 흔히 PlayStation, XBox 등의 콘솔 게임에서 캐릭터의 이동 방향을 조정하기 위해 사용된다. 그 외에도 우리 생활 주변에서 다양한 용도로 사용되고 있다. 여러 기능을 갖춘 복잡한 조이스틱들도 있지만, 조이스틱의 기본 기능은 스틱을 조작하여 2차원 평면상에서 xy 좌표값을 지정하고 이 값을 이용하여 어떤 대상을 제어하는 것이다. 그래서 RC카, 드론 등과 같이 원격에 있는 대상을 조정하는 용도로 주로 사용된다. 안드로이드 폰 역시 BT, BLE 등을 통해 다른 장치와 원격으로 연결이 가능하다. 그렇다면 안드로이드 앱으로 조이스틱 기능을 사용하여 리모트 컨트롤러(RC)로 활용할 수 있다. 하지만 안드로이드에는 기본으로 제공되는 조이스틱 위젯이 없기 때문에 필요하다면 커스텀 뷰로 만들어야 한다. 최근에 취미로 메카넘 휠 차량을 만들고 있는데, 이번 기회에 안드로이드 폰에서 BLE 통신과 조이스틱 기능을 앱으로 만들어 두면 나중에 다른 장치도 원격으로 제어할 수 있어 유용할 것 같았다. 그래서 이 글에서는 안드로이드 폰에서 동작하는 조이스틱 기능을 커스텀 뷰로 간단히 만들어 보는 과정을 소개한다.

CH Products Mach 2 analog joystick


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을 추가해 보자.

  1. 'joystick' 모듈에서 마우스 우클릭 > New > Android Resouce Directory 순서로 이동한다.
  2. Directory name으로 설정되어있는 기본값인 'values'를 그대로 사용하면 되므로 따로 수정 없이'OK' 버튼을 클릭한다.
  3. res/values 가 생성된 것을 확인한다.
  4. 'res/values' 디렉터리에서 마우스 우클릭 > New > Values Resource File 순서로 이동한다.
  5. 'New Resource File' 창의 'File name' 필드가 비어있는데, 여기에 "attrs"라고 입력 후 'OK' 버튼을 클릭한다.
  6. 추가된 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 클래스 추가 과정은 다음과 같다.

  1. joystick 모듈 > java > com.monotics.view.joystick에서 마우스 우클릭한다.
  2. New > Kotlin Class/File으로 이동한다.
  3. '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)
    }
}

 

전체 코드는 여기에서 확인할 수 있다.

 

테스트

앱을 실행해서 스틱을 움직여보면 아래 그림과 같이 각도 값과 세기 값이 변하는 것을 확인할 수 있다.

Joystick 테스트 (각도: 46°, 세기: 72)

 


전체 코드 (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

댓글