uniapp 実装:カスタム円形スライダークラスコンポーネントの構築

概 要

モバイルアプリケーション開発において、直線的なスライダーだけでなく、方位や回転量を指定するための円形コントローラが必要なケースがあります。本記事では、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` を用いたイージング関数を適用し、滑らかに原点へ復元します。

タグ: UniApp javascript vue.js CSS TouchEvent

8月22日 19:15 投稿