マイコンボード等から得られる2つの信号をスマホに送りオシロスコープのX-Yモード表示のように、二次元でリアルタイムにグラフ表示したいと思いました。手始めにスマホ自体に内蔵されている重力センサのX軸方向の値と、Y軸方向の値をX-Y二次元グラフにリアルタイム表示するアプリを作ってみましたので紹介します。
ソフトウェア開発ツールはAndroid Studio(バージョン:Giraffe/2022.3.1 Patch3)を使用し、プログラミング言語はKotlinを使用しました。
1.作成アプリの動作動画
以下は今回作成したアプリの動作動画です。スマホを傾けると重力値が変化するのでX-Y二次元グラフがリアルタイムに描かれます。(横軸:X軸方向重力値、縦軸:Y軸方向の重力値、それぞれ単位はm/s²です)
表示されたグラフをスクロールしたり、拡大、縮小したりすることが出来ます。
2.重力センサに関して
X-Y二次元グラフを作成するための2個のリアルタイムデータとして、スマホに内蔵している重力センサ値を使用しました。
このセンサはスマホにかかる重力をX軸方向、Y軸方向、Z軸方向に関してm/s²で検出します。スマホの傾きを変えるとそれぞれの値は変化します。今回のアプリではX軸方向の重力値とY軸方向の重力値を使用しました。
重力センサを利用するにはSensor Managerクラスを使用します。下記のようにgetDefaltSensorメソッドで引数にTYPE_GRAVITYを指定することによって、重力センサが使用できます。
lateinit var manager: SensorManager
・
・
manager = gerSystemService(Context.SENSOR_SERVICE) as SensorManager
・
・
val sensor = manager.getDefaultSensor(Sensor.TYPE_GRAVITY)
定数名 | 内容 |
TYPE_GRAVITY | X軸、Y軸、Z軸方向の重力をm/s²単位で測定する。 |

3.二次元グラフのカスタムViewの作製
作成したアプリでは、二次元グラフを表示するカスタムViewを作成しました。
作製したカスタムViewの名称と、Publicなプロパティとメソッドを表2と表3に示します。
◎カスタムViewの名称: ArtView
◎プロパティ
プロパティ | 役割 |
dpPerMperS2: Float | 重力センサの値は1m/s²単位で呼びだされますが、折れ線グラフを描く時に重力センサの値1m/s²に対して、Viewの画面上で何dpとするかの値です。 この値を変更することによって、グラフの拡大、縮小ができます。 |
xOffset: Float | 二次元グラフのX軸原点(中心点)の値で、単位は1m/s²です。 この値を変更することによって、グラフの横方向のスクロールができます。 |
yOffset: Float | 二次元グラフのY軸原点(中心点)の値で、単位は1m/s²です。 この値を変更することによって、グラフの縦方向のスクロールができます。 |
◎メソッド
メソッド | 役割 |
drawPathLine(xData: Float, yData: Float) | 折れ線グラフに指定されたデータを追加して描きます。 (引数) xData X軸方向の重力値(単位1m/s²) yData Y軸方向の重力値(単位1m/s²) |
drawClr() | 描かれた折れ線グラフをクリアします。 |
3-1.カスタムViewのKotlinプログラム
二次元グラフを表示するカスタムViewのKotlinプログラムを4つに分割して示します。
①クラス定義、プロパティ、onDraw()メソッド部分のコード
カスタムViewのクラス定義と、プロパティ定義と、AndroidがViewを表示、変更する時とinvalidate()メソッドを実効した時に呼び出されるonDraw()メソッド部分のソースコードを以下に示します。onDraw()メソッド内に全ての描画処理を記述します。
class ArtView(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val xCent: Float = 200f //X軸中心位置(200dp)
private val yCent: Float = 200f //Y軸中心位置(200dp)
private val dens = resources.displayMetrics.density //画面論理密度
var dpPerMperS2: Float = 50f //1m/s²に対する画面のdp(1m/s²: 50dp)
var xOffset: Float = 0f //X軸のオフセット(単位 m/s²)
var yOffset: Float = 0f //Y軸のオフセット(単位 m/s²)
//Pathの初期プロットのフラグ
private var firstPlotFlag: Boolean = true
//プロットデータのデータクラス宣言
data class dataPlot(val x: Float, val y: Float){
var xData = x;
var yData = y;
}
var plotDatas: ArrayList<dataPlot> = ArrayList()
private val path = Path()
//プロット線用の描画スタイル定義
private val pLine = Paint().apply {
//各プロパティ定義
color = Color.GREEN
strokeWidth = 2f * dens
style = Paint.Style.STROKE
strokeJoin = Paint.Join.ROUND
}
//Viewが表示、変更される時に呼び出されるメソッド
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//ビューに対して描画
canvas.drawColor(Color.WHITE) //canvasの色指定
canvas.drawPath(path, pLine) //Pathラインの表示
scaleDisp(canvas) //スケール表示
}
・
・
・
【説明】
1行目:
class ArtView(context: Context, attrs: AttributeSet) : View(context, attrs)
はカスタムViewのクラス宣言です。カスタムViewはViewクラスを継承します。
カスタムViewクラスの名称をArtViewとしました。
2行目~7行目:
主なプロパティに関して下表で説明します。
プロパティ名 | 役割 |
private val xCent: Float = 200f | グラフのX軸座標の中心のView画面上の位置です。(単位dp) |
private val yCent: Float = 200f | グラフのY軸座標の中心のView画面上の位置です。(単位dp) |
private val dens = resources.displayMetrics.density | 画面の論理密度です。 resources.displayMetrics.density で得られる論理密度は、1インチ当たりのピクセル数(dpi)を160で割り算したものです。 dp値に、このdensを乗算することによってピクセル値に変換することが出来ます。 |
var dpPerMperS2: Float = 50f | グラフをプロットする時、重力センサの値1m/s²に対して、画面上で何dpとするかの値です。 |
var xOffset: Float = 0f | X-Yの二次元座標のX軸座標の原点オフセット量(原点の値)で単位はm/s²です。グラフをX軸方向にスクロールする時に、この値を変更します。 |
var yOffset: Float = 0f | X-Yの二次元座標のÝ軸座標の原点オフセット量(原点の値)で単位はm/s²です。グラフをÝ軸方向にスクロールする時に、この値を変更します。 |
13行目:
data class dataPlot(val x: Float, val y: Float){}
は、プロットしたデータを記憶しておくためのデータクラスです。17行目のplotDatasのアレイリストを使用して、過去にプロットしたデータを記憶しておきます。
この記憶したデータは、グラフのスライド操作によるスクロールや、ピンチ操作時の再描画に使用します。
22行目:
private val pLine = Paint().apply{}
でグラフ線の描画のスタイルを指定しています。
31行目:
override fun onDraw(canvas: Canvas) {}
このメソッドはAndroidがViewを表示、変更する時に呼び出されます。またinvalidate()メソッドを実効すると、このメソッドが呼び出されます。このメソッド内で全ての描画の処理を行います。
引数canvas: CanvasはViewへの描画を行うオブジェクトです。
35行目:
canvas.drawColor(Color.WHITE)
はキャンバスの背景色を白に指定します。
36行目:
canvas.drawPath(path, pLine)
はpath: Passのデータにそって、pLineの描画スタイルで折れ線を表示します。
Passのデータの作製に関しては②項で説明します。
メソッド | 内容 |
fun drowPath(path: Path, paint: Paint) | Pathに沿って図形描画する (引数) path: パス paint: 描画スタイル |
37行目:
scaleDisp(canvas)
はX-Y二次元グラフのX軸線とY軸線とそれぞれの目盛値を表示するメソッドです。ソースコードは④項を参照してください。
②重力データからのPathの構築・描画と描画クリア部分のコード
drawPathLine()メソッドは、引数で指定したXデータ(X軸重力値)とYデータ(Y軸重力値)をPathに追加した後に、Pathのデータにそって折れ線を描画するメソッドです。
drawClear()メソッドは、描画した折れ線をクリアするメソッドです。
それらのメソッドのソースコードを以下に示します。
//Pathにプロット点を追加して描画
fun drawPathLine(xData: Float, yData: Float){
val data = dataPlot(xData, yData)
plotDatas.add(data) //前回までのプロットデータに新プロットデータを追加して記憶
//X,Y値をピクセル位置に変換
val xPix = (xCent + (-xOffset + xData) * dpPerMperS2) * dens
val yPix = (yCent + (yOffset - yData) * dpPerMperS2) * dens
if (firstPlotFlag == true){ //初期プロット点?
firstPlotFlag = false //初期プロット点フラグリセット
path.moveTo(xPix, yPix)
}
else{
path.lineTo(xPix, yPix)
}
invalidate() //onDraw()メソッドを呼び出す
}
//プロットした表示全クリア
fun drawClr(){
plotDatas.clear() //記憶したプロットデータをクリアする
path.reset() //パスのクリア
xOffset = 0f //X軸オフセットクリア
yOffset = 0f //Y軸オフセットクリア
dpPerMperS2 = 50f //1m/S²のdp値初期セット
firstPlotFlag = true //初期プロット点フラグフラグセット
invalidate() //onDraw()メソッドを呼び出す
}
【説明】
2行目:
drawPathLine()メソッドは、引数で指定されたプロット点をPathに追加した後に、Pathに従って折れ線を描画するメソッドです。
4行目:
グラフをスライド操作でスクロールする場合と、ピンチ操作で拡大・縮小をする場合にそなえて、プロットしたデータをplotDatas.add(data)でアレイリストに記憶しておきます。
7,8行目:
val xPix = (xCent + (-xOffset + xData) * dpPerMperS2) * dens
val yPix = (yCent + (yOffset – yData) * dpPerMperS2) * dens
で重力センサのX軸方向の値とY軸方向の値を、画面のピクセル値に変換しています。
重力センサの値は、m/s²単位で得られます。dpPerMperS2は、1m/s²の値がViewの画面上で何dpであるかを示す変数です。重力値にdpPerMperS2を乗算することによって、重力値の値に対応した画面上のdp単位での位置を計算しています。
densは画面の論理密度で、dp値に論理密度を乗算することによって、ピクセル値を得ることができます。
10行目~16行目:
グラフにプロットする座標値(X軸方向のピクセル位置とY軸方向のピクセル位置)をPathに追加します。
最初のデータは、moveTo()メソッドを使用してPathに追加します。途中経過のデータはlineTo()メソッドを使用してPathに追加します。moveTo()メソッドとlineTo()メソッドに関して表5に示します。
firstPlotFlagを使用してどちらのメソッドを使用するか判断しています。
メソッド | 内容 |
fun moveTo(x:Float, y: Float) | パスの最初の点となる座標を指定 (引数) x: X座標値(単位はピクセル) y: Y座標値(単位はピクセル) |
fun lineTo(x: Float, y: Float) | パスの終点または経由点の座標を指定 続けて呼び出すことによって、連続した複数の直線(折れ線)を表現出来る (引数) x: X座標値(単位はピクセル) y: Y座標値(単位はピクセル) |
18行目:
invalidate()メソッドを呼び出すと、onDraw()メソッドが呼び出されて再描画が行われます。
22行目:
drawClr()メソッドは、描画された折れ線をクリアするメソッドです。
24行目、29行目:
描画した折れ線グラフをクリアするdrawClr()メソッドでは、24行目のpath.reset()でPathをクリアした後に、29行目のinvalidate()メソッドからonDraw()メソッドを呼び出して、クリアしたPathを再描画することによって折れ線グラフを消しています。
③スライドによるスクロールとピンチによる拡大、縮小部分のコード
グラフのスライド操作によるスクロールと、ピンチ操作による拡大縮小に関する部分のソースコードを以下に示します。
//----スクロールとピンチ処理---------------------
private val mScaleDetector = ScaleGestureDetector(context, ScaleListener())
private val mGestureDetector = GestureDetector(context, GestureListener())
//画面タッチで呼び出されるメソッド
override fun onTouchEvent(event: MotionEvent): Boolean {
mScaleDetector.onTouchEvent(event)
mGestureDetector.onTouchEvent(event)
return true
}
//SimpleOnScaleGestureListener(ピンチ時のリスナ)
inner class ScaleListener: ScaleGestureDetector.SimpleOnScaleGestureListener(){
override fun onScale(detector: ScaleGestureDetector): Boolean {
val scale: Float = detector.scaleFactor
//1m/s²に対する画面dpを変更する
dpPerMperS2 = dpPerMperS2 * scale
//パスの再生とグラフの再表示
reMakePath()
return true
}
}
//SimpleOnGestureListerner(スクロール時のリスナ)
inner class GestureListener: GestureDetector.SimpleOnGestureListener(){
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
//X軸、Y軸のオフセット値計算
val xOfs = xOffset + distanceX / (dens * dpPerMperS2)
//0.001以下を切り捨てる
xOffset = (Math.floor(xOfs.toDouble() * 1000.0) / 1000.0).toFloat()
val yOfs = yOffset - distanceY / (dens * dpPerMperS2)
//0.001以下を切り捨てる
yOffset = (Math.floor(yOfs.toDouble() * 1000.0) / 1000.0).toFloat()
//パスの再生とグラフの再表示
reMakePath()
return true
}
}
//パスの再生と再表示
fun reMakePath(){
//今までのPathの目盛に対応した作り直し
path.reset() //パスのクリア
firstPlotFlag = true
plotDatas.forEach {
val data = it
val xData = data.xData
val yData = data.yData
//X,Y位置をピクセル位置に変換してPathを再作成
val xPix = (xCent + (-xOffset + xData) * dpPerMperS2) * dens
val yPix = (yCent + (yOffset - yData) * dpPerMperS2) * dens
if (firstPlotFlag == true){
firstPlotFlag = false
path.moveTo(xPix, yPix)
} else {
path.lineTo(xPix, yPix)
}
}
invalidate() //onDraw()メソッドを呼び出す
}
【説明】
2行目:
private val mScaleDetector = ScaleGestureDetector(context, ScaleListener())
のScaleGestureDetectorは、ピンチ操作による拡大、縮小イベントを検出するのに使用しています。
3行目:
private val mGestureDetector = GestureDetector(context, GestureListener())
のGestureDetectorは、スライド操作による座標の移動(スクロール)を検出するのに使用しています。
6行目:
override fun onTouchEvent(event: MotionEvent)
このメソッドは画面へ指をタッチした時に発生するイベントメソッドです。ScaleGestureDetectorとGestureDetectorのリスナを起動しています。
13行目:
inner class ScaleListener: ScaleGestureDetector.SimpleOnScaleGestureListener(){}
はピンチ操作による拡大、縮小イベントを検出するためのリスナクラスです。
15行目:
val scale: Float = detector.scaleFactor
でピンチ操作した時のスケールを得ています。この値によってグラフの1m/s²に対する画面dp値(dpPerMperS2)を変更しています。
18行目:
dpPerMperS2 = dpPerMperS2 * scale
でピンチ操作時のスケール値を1m/s²に対する画面dp値(dpPerMperS2)に乗算することによって、新たなdpPerMperS2を得ています。この値を使用してグラフを再描画することによって拡大、縮小を行っています。
26行目:
inner class GestureListener: GestureDetector.SimpleOnGestureListener(){}
はスライド操作による移動量を検出するためのリスナクラスです。
X軸方向とY軸方向へのスライド距離distanceXとdistanceYから、グラフのX軸方向のオフセット値とY軸方向のオフセット値を計算しています。
34行目~36行目:
val xOfs = xOffset + distanceX / (dens * dpPerMperS2)
でスライド操作によるX軸方向の移動量distanceX (ピクセル単位) からX軸方向の原点オフセット量(m/s²単位)を得ています。
xOffset = (Math.floor(xOfs.toDouble() * 1000.0) / 1000.0).toFloat()
で得られたオフセット量の0.001未満を切り捨てています。
Y軸方向に関しても同様の処理を行っています。
47行目:
fun reMakePath()はピンチ操作やスライド操作で変更された、グラフの1m/s²に対する画面dp値(dpPerMperS2)と、グラフのX軸方向のオフセット値(xOffset)とY軸方向のオフセット値(yOffset)に基づいてグラフを再描画するメソッドです。
plotDatasアレイリストに記憶された過去のプロットデータに基づいてPathを再作成してグラフを再描画しています。
④グラフの目盛り軸の表示部分のコード
グラフの横軸(X軸)と縦軸(Y軸)と目盛の数値を表示する部分のソースコードを以下に示します。
詳細説明は省きますが、X軸とY軸の10dp毎に目盛線を描き、50dpの倍数の部分は長めの目盛線を描いています。
また、50dpの倍数の目盛線の場所に目盛値を描画しています。
尚、目盛値は0.001未満は切り捨てています。
//--------目盛線の表示-----------------------
fun scaleDisp(canvas: Canvas) {
drawXaxis(canvas) //X軸の表示
drawYaxis(canvas) //Y軸の表示
drawXscale(canvas) //X軸の目盛り表示
drawYscale(canvas) //Y軸の目盛り表示
}
//X軸(横軸)の表示
fun drawXaxis(canvas: Canvas){
//目盛線用描画スタイル
val pa1 = Paint().apply {
color = Color.BLUE
strokeWidth = 1.5f * dens
}
//目盛線の延長線用描画スタイル
val pa2 = Paint().apply {
color = Color.BLUE
strokeWidth = 0.5f * dens
}
//X軸を引く (中心-190dp)~(中心+190dp)
var x1Pos = (xCent -190f) * dens
var y1Pos = yCent * dens
var x2Pos = (xCent + 190f) * dens
var y2Pos = yCent * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
//10dp毎に目盛り線を引く
for (i in 1..19){
//中心から50dp,100dp,150dpは長い線
if ((i == 5) || (i == 10) || (i == 15)){ //長めの線
//中心から右側
x1Pos = (xCent + i * 10) * dens
y1Pos = (yCent - 5) * dens
x2Pos = (xCent + i * 10) * dens
y2Pos = (yCent + 5) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
//中心から左側
x1Pos = (xCent - i * 10) * dens
x2Pos = (xCent - i * 10) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
}
else { //中心から50dp,100dp,150dp以外は短い線
//中心から右側
x1Pos = (xCent + i * 10) * dens
y1Pos = (yCent - 2) * dens
x2Pos = (xCent + i * 10) * dens
y2Pos = (yCent + 2) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
//中心から左側
x1Pos = (xCent - i * 10) * dens
x2Pos = (xCent - i * 10) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
}
}
//横軸の目盛り線延長を引く
for (i in 1..3){ //中心から50dp,100dp,150dpの位置
//中心から右側
var x1Pos = (xCent -190f) * dens
var y1Pos = (yCent + i * 50) * dens
var x2Pos = (xCent + 190f) * dens
var y2Pos = (yCent + i * 50) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
//中心から左側
y1Pos = (yCent - i * 50) * dens
y2Pos = (yCent - i * 50) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
}
}
//Y軸(縦軸)の表示
fun drawYaxis(canvas: Canvas){
//目盛線用描画スタイル
val pa1 = Paint().apply {
color = Color.BLUE
strokeWidth = 1.5f * dens
}
//目盛線の延長線用描画スタイル
val pa2 = Paint().apply {
color = Color.BLUE
strokeWidth = 0.5f * dens
}
//Y軸を引く (中心-190dp)~(中心+190dp)
var x1Pos = xCent * dens
var y1Pos = (yCent - 190f) * dens
var x2Pos = xCent * dens
var y2Pos = (yCent + 190f) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
//10dp毎に目盛り線を引く
for (i in 1..19) {
//中心から50dp,100dp,150dpは長い線
if ((i == 5) || (i == 10) || (i == 15)) { //長めの線
//中心から上側
x1Pos = (xCent - 5) * dens
y1Pos = (yCent + i * 10) * dens
x2Pos = (xCent + 5) * dens
y2Pos = (yCent + i * 10) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
//中心から下側
y1Pos = (yCent - i * 10) * dens
y2Pos = (yCent - i * 10) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
} else { ////中心から50dp,100dp,150dp以外は短い線
//中心から上側
x1Pos = (xCent - 2) * dens
y1Pos = (yCent + i * 10) * dens
x2Pos = (xCent + 2) * dens
y2Pos = (yCent + i * 10) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
//中心から下側
y1Pos = (yCent - i * 10) * dens
y2Pos = (yCent - i * 10) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
}
}
//縦軸の目盛り線延長を引く
for (i in 1..3){ //中心から50dp,100dp,150dpの位置
//中心から上側
var x1Pos = (xCent - i * 50) * dens
var y1Pos = (yCent - 190f ) * dens
var x2Pos = (xCent - i * 50) * dens
var y2Pos = (yCent + 190f) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
//中心から下側
x1Pos = (xCent + i * 50) * dens
x2Pos = (xCent + i * 50) * dens
canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
}
}
//X軸の目盛り表示
fun drawXscale(canvas:Canvas){
//文字用描画スタイル
val pText = Paint().apply{
color = Color.BLACK
strokeWidth = 5f * dens
typeface = Typeface.SERIF
textSize = 15f //テキストサイズ(sp)
textAlign = Paint.Align.CENTER //表示位置
textScaleX = 1.5f //水平方向への拡大率
textSkewX = -0.5f //水平方向にずらす度合
}
for (i in 0..3){
//中心から横へ50dp, 100dp, 150dpの位置に数値表示
var xPos = (xCent + i * 50f) * dens
var yPos = (yCent + 15f) * dens
var tex = changeString(i * 50f / dpPerMperS2 + xOffset)
canvas.drawText(tex, xPos, yPos, pText)
//中心から横へ-50dp, -100dp, -150dpの位置に数値表示
xPos = (xCent - i * 50f) * dens
tex = changeString(i * -50f / dpPerMperS2 + xOffset)
canvas.drawText(tex, xPos, yPos, pText)
}
//"X"の表示
var xPos = (xCent + 190f) * dens
var yPos = (yCent - 5f) * dens
canvas.drawText("X", xPos, yPos, pText)
}
//Y軸の目盛り表示
fun drawYscale(canvas: Canvas){
//文字用描画スタイル
val pText = Paint().apply{
color = Color.BLACK
strokeWidth = 5f * dens
typeface = Typeface.SERIF
textSize = 15f //テキストサイズ(sp)
textAlign = Paint.Align.LEFT //表示位置
textScaleX = 1.5f //水平方向への拡大率
textSkewX = -0.5f //水平方向にずらす度合
}
for (i in 0..3){
//中心から下へ50dp, 100dp, 150dp付近に数値表示
var xPos = (xCent + 5f) * dens
var yPos = (yCent + i * 50f - 5f) * dens
var tex = changeString(i * -50f / dpPerMperS2 + yOffset)
canvas.drawText(tex, xPos, yPos, pText)
//中心から上へ-50dp, -100dp, -150dp付近に数値表示
yPos = (yCent - i * 50f - 5f) * dens
tex = changeString(i * 50f / dpPerMperS2 + yOffset)
canvas.drawText(tex, xPos, yPos, pText)
}
//"Y"の表示
val xPos = (xCent - 15f) * dens
val yPos = (yCent - 185f) * dens
canvas.drawText("Y", xPos, yPos, pText)
}
//0.001以下を切り捨て文字列化
fun changeString(value: Float): String{
//0.001以下切り捨て
val dataTxt = (Math.floor(value.toDouble() * 1000.0) / 1000.0).toString()
return dataTxt
}
4.カスタムViewの画面への配置
作製したアプリ画面のレイアウトを以下に示します。
アプリ画面のレイアウトは、作成したX-Y二次元グラフのカスタムViewとButtonを3個を配置しました。表6に各Viewのidと役割に関して記載します。

名称 | 種類 | id | 役割 |
カスタムView (ArtView) | 二次元グラフ | art_view | X軸、Y軸方向の重力をリアルタイムに二次元グラフ表示 |
STARTボタン | Button | startButton | 重力センサの検出とグラフ表示を開始する |
STOPボタン | Button | stopButton | 重力センサの検出とグラフ表示を停止する |
CLEARボタン | Button | clearButton | 描画したグラフを消去する |
尚、X-Y二次元グラフのカスタムView (ArtView)は、下図の様にDesign画面のPaletteのContainers→<View>をデザインの画面上にドラッグすると、Viewsの選択画面が表示されるので、作成したカスタムViewの名称ArtViewを選択して「OK」をクリックすると画面上に配置することができます。

画面レイアウトに対応したXMLのコード(app→res→layout→activity_main.xml)を以下に示します。
<?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">
<view
android:id="@+id/art_view"
class="com.ri_control.x_y_plot_test.ArtView"
android:layout_width="400dp"
android:layout_height="400dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/startButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="68dp"
android:text="START"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/art_view" />
<Button
android:id="@+id/stopButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="68dp"
android:text="STOP"
app:layout_constraintStart_toEndOf="@+id/startButton"
app:layout_constraintTop_toBottomOf="@+id/art_view" />
<Button
android:id="@+id/clearButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="68dp"
android:text="CLEAR"
app:layout_constraintStart_toEndOf="@+id/stopButton"
app:layout_constraintTop_toBottomOf="@+id/art_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
5.MainActivityプログラム(重力値の読み込みと描画)
MainActivityクラスではスマホの重力センサから重力値を読込、カスタムView(二次元グラフ)に表示する操作をおこないます。その部分のソースコードを以下に示します。
class MainActivity : AppCompatActivity(), SensorEventListener {
//センサーマネージャ
private lateinit var sensManager: SensorManager
//スタートフラグ
private var startFlag: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//センサーマネージャ設定
sensManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
//押し釦スイッチのOnClickListenerの設定
val staButton = findViewById<Button>(R.id.startButton)
staButton.setOnClickListener(StartButtonOnClickListener())
val stpButton = findViewById<Button>(R.id.stopButton)
stpButton.setOnClickListener(StopButtonOnClickListener())
val clrButton = findViewById<Button>(R.id.clearButton)
clrButton.setOnClickListener(ClearButtonOnClickListener())
}
override fun onResume() {
super.onResume()
//重力センサ指定
val accSensor = sensManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
//最高通知速度指定
sensManager.registerListener(this, accSensor, SensorManager.SENSOR_DELAY_FASTEST)
}
override fun onPause() {
super.onPause()
//センサマネージャ登録解除
sensManager.unregisterListener(this)
}
//センサの値が変更された
override fun onSensorChanged(event: SensorEvent?) {
if (startFlag == true) { //スタートしている?
val valX = event?.values?.get(0) //X軸重力
val valY = event?.values?.get(1) //Y軸重力
if ((valX != null) && (valY != null)) {
//グラフの表示
val artView = findViewById<ArtView>(R.id.art_view)
artView.drawPathLine(valX, valY)
}
}
}
//センサの精度が変更された
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
}
//スタートボタンのOnClickListener
private inner class StartButtonOnClickListener : View.OnClickListener{
override fun onClick(v: View?) {
startFlag = true //スタートフラグセット
}
}
//ストップボタンのOnClickListener
private inner class StopButtonOnClickListener : View.OnClickListener{
override fun onClick(v: View?) {
startFlag = false //スタートフラグリセット
}
}
//クリアボタンのOnClickListener
private inner class ClearButtonOnClickListener : View.OnClickListener{
override fun onClick(v: View?) {
val artView = findViewById<ArtView>(R.id.art_view)
artView.drawClr() //プロット点のクリア
}
}
}
【説明】
3行目:
private lateinit var sensManager: SensorManager
センサーマネージャーのインスタンス用変数です。lateinitとして、onCreate()メソッド内で初期化します。
12行目:
sensManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
重力センサを使用するので、センサーマネージャーを取得しています。重力センサの指定はonResume()メソッド内で行います。
27行目~30行目:
アプリが起動されて、画面が表示された時に呼び出されるonResume()メソッド内に重力センサの設定を記載します。
val accSensor = sensManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
の「Sensor.TYPE_GRAVITY」で重力センサ(X,Y,Z軸方向)を指定しています。
sensManager.registerListener(this, accSensor, SensorManager.SENSOR_DELAY_FASTEST)
で重力センサ値が変化した場合に呼び出されるイベントリスナを登録しています。
SENSOR_DELAY_FASTESTは以下の様に最も早い通知頻度を指定します。
定数名 | 内容 |
SENSOR_DELAY_NORMAL | 通常の通知頻度 |
SENSOR_DELAY_FASTEST | 最も早い通知頻度 |
SENSOR_DELAY_GAME | ゲームに適した通知頻度 |
SENSOR_DELAY_UI | ユーザーインターフェイス利用に適した通知頻度 |
36行目:
sensManager.unregisterListener(this)
は、アプリが一時停止した時に呼び出されるonPause()メソッド内でセンサ-マネージャーのイベントリスナの登録抹消を行っています。
40行目:
override fun onSensorChanged(event: SensorEvent?)
は重力センサの値が変化した時に呼び出されるメソッドです。
42,43行目:
val valX = event?.values?.get(0)
val valY = event?.values?.get(1)
でX軸方向とY軸方向の重力値をm/s²単位で呼び出しています。
46,47行目
val artView = findViewById(R.id.art_view)
artView.drawPathLine(valX, valY)
でカスタムView (id: art_view)のdrawPathLine()メソッドを呼び出して重力値を二次元グラフに表示します。
72行目:
private inner class ClearButtonOnClickListener : View.OnClickListener
は、CLEARボタンが押された時のイベントリスナです。
75行目:
カスタムView(id: art_view)のdrawClr()メソッドを呼び出して、描かれたグラフのクリアを行っています。
6.最後に
今回作成したアプリでは、スマホに内蔵された重力センサの値をX-Y二次元グラフに表示しました。
本アプリを作りたいと思ったのは、マイコンボード等から得られる2つの信号をスマホに送りオシロスコープのX-Yモード表示のようなモニタツールを製作することでした。
今後はマイコンボードで得られた信号をWi-Fi等でスマホに送りリアルタイムに二次元グラフ表示するアプリを考えたいと思います。