概 要
モバイルアプリケーション開発において、直線的なスライダーだけでなく、方位や回転量を指定するための円形コントローラが必要なケースがあります。本記事では、uniapp フレームワーク上で動作する、カスタム円形ジョイスティック(ラジアルスライダー)コンポーネントの実装方法について解説します。
このコンポーネントは、タッチ操作によって円周内を移動するハンドルを備え、中心からの距離と角度を計算して親コンポーネントへ通知します。離した際にはアニメーション効果で原点に戻る挙動も実装済みです。
コンポーネント実装コード
以下のコードは、Options API をベースに構成しています。タッチイベントの処理、座標計算、およびアニメーション制御を含む完全なバージョンです。
<template>
<view
class="radial-track"
:style="trackStyles"
@touchstart.prevent="handleTouchStart"
>
<view
ref="radialThumb"
class="radial-thumb"
:style="thumbStyles"
@touchmove.stop.prevent="handleTouchMove"
@touchend.stop.prevent="handleTouchEnd"
></view>
</view>
</template>
<script>
export default {
name: 'RadialController',
props: {
size: {
type: [Number, String],
default: 200
},
handleRadius: {
type: Number,
default: 25
}
},
data() {
return {
offset: { x: 0, y: 0 },
origin: { x: 0, y: 0 },
isActive: false,
initialCoords: null,
animateTimer: null,
prevAngle: 0,
prevRatio: 0
};
},
methods: {
calculateCenter() {
const s = parseInt(this.size);
const h = this.handleRadius * 2;
this.origin = {
x: (s - h) / 2,
y: (s - h) / 2
};
},
handleTouchStart(e) {
e.stopPropagation();
if (this.animateTimer) clearTimeout(this.animateTimer);
this.isActive = true;
const t = e.touches[0];
this.initialCoords = {
pageX: t.pageX,
pageY: t.pageY
};
// ハaptic feedback
try {
uni.vibrateShort({ success: () => {} });
} catch (err) {}
},
handleTouchMove(e) {
if (!this.isActive || !this.initialCoords) return;
e.stopPropagation();
const now = Date.now();
if (now - this.lastFrameTime < 16) return; // 60fps throttling
this.lastFrameTime = now;
const t = e.touches[0];
const dx = t.pageX - this.initialCoords.pageX;
const dy = t.pageY - this.initialCoords.pageY;
const maxRadius = parseInt(this.size) / 2 - this.handleRadius - 5;
const distRaw = Math.sqrt(dx * dx + dy * dy);
// 制限半径のキャリング
let finalDist = distRaw;
let clampedX = dx;
let clampedY = dy;
if (distRaw > maxRadius) {
const ratio = maxRadius / distRaw;
clampedX = dx * ratio;
clampedY = dy * ratio;
finalDist = maxRadius;
}
// 位置更新
this.offset = {
x: this.origin.x + clampedX,
y: this.origin.y + clampedY
};
// データ送信ロジック
const currentAngle = Math.round(Math.atan2(dy, dx) * (180 / Math.PI) * 100) / 100;
const currentRatio = Math.round((finalDist / maxRadius) * 100) / 100;
if (Math.abs(currentAngle - this.prevAngle) > 0.1 || Math.abs(currentRatio - this.prevRatio) > 0.1) {
this.prevAngle = currentAngle;
this.prevRatio = currentRatio;
this.$emit('input', { angle: currentAngle, intensity: currentRatio });
}
},
handleTouchEnd(e) {
if (!this.isActive) return;
e.stopPropagation();
this.isActive = false;
const startPos = { ...this.offset };
const startTime = Date.now();
const duration = 400;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Cubic Ease Out
const ease = 1 - Math.pow(1 - progress, 3);
this.offset = {
x: startPos.x + (this.origin.x - startPos.x) * ease,
y: startPos.y + (this.origin.y - startPos.y) * ease
};
if (progress < 1) {
this.animateTimer = requestAnimationFrame(animate);
} else {
this.$emit('input', { angle: 0, intensity: 0 });
this.$emit('change', { angle: 0, intensity: 0 });
}
};
animate();
}
},
computed: {
trackStyles() {
return {
width: `${this.size}px`,
height: `${this.size}px`,
backgroundColor: 'rgba(0,0,0,0.2)',
borderRadius: '50%',
position: 'relative'
};
},
thumbStyles() {
return {
width: `${this.handleRadius * 2}px`,
height: `${this.handleRadius * 2}px`,
transform: `translate(${this.offset.x}px, ${this.offset.y}px)`,
left: 0,
top: 0,
marginLeft: `-${this.handleRadius}px`,
marginTop: `-${this.handleRadius}px`
};
}
},
mounted() {
this.calculateCenter();
},
beforeDestroy() {
if (this.animateTimer) cancelAnimationFrame(this.animateTimer);
}
}
</script>
<style>
.radial-track {
box-sizing: border-box;
overflow: hidden;
}
.radial-thumb {
position: absolute;
border-radius: 50%;
background-color: #ffffff;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
pointer-events: none; /* タッチパススルーを防止するために親で処理 */
}
</style>
親画面での統合方法
作成したコンポーネントを使用する場合、親ビュー内でのスタイル設定およびイベントリスニングが必要です。以下は垂直モードで使用される簡易的な実装例です。
<template>
<view class="control-container">
<RadialController
:size="180"
:handle-radius="20"
@input="onDirectionChange"
/>
</view>
</template>
<script>
import RadialController from '@/components/RadialController.vue';
export default {
components: {
RadialController
},
methods: {
onDirectionChange(data) {
console.log(`角度:${data.angle},強度:${data.intensity}`);
// ここに実際の制御ロジックを追加
}
}
}
</script>
<style lang="scss">
.control-container {
width: 100%;
padding: 20px;
display: flex;
justify-content: center;
touch-action: none; /* スクロール競合を防ぐ */
}
</style>
主要な処理ロジック
実装の核心となるのは、タッチ座標から相対ベクトルを導出し、それを円の半径範囲内に収める処理です。
- 距離の正規化: ピタゴラスの定理を用いて、現在点から中心までの距離を算出します。この値が最大半径を超えた場合、ベクトル方向を維持しつつ長さを制限します。
- 角度計算: `Math.atan2` 関数を使用して、XY 平面上の角度を取得し、デグレに換算します。
- 戻りアニメーション: リリース時に `requestAnimationFrame` を用いたイージング関数を適用し、滑らかに原点へ復元します。