はじめに
iOSアプリ「ニャンバープレイス」では、ルールに「カスタム」を選択し、配置パターンを「ランダム」にすると、空白セルの数を範囲指定のスライダーで調整できます。
標準ではこのようなスライダーが用意されていないようだったので、勉強がてら自分で作成することにしました。
要件
- 範囲指定できるスライダーを実装する
- 整数値のみをとる
- 下限値と上限値を同じ値にすると"つまみ"が重なって操作しづらくなるため、下限値は上限値-1を最大値、上限値は下限値+1を最小値とする
- 範囲は0から9までとするが、ニャンバープレイスではフリープランだと範囲は0〜3までに制限している。ただし、単純に最大値を3と9とで切り替えると、パッと見たときの印象が変わって都合が悪い。そのため、あくまで右端は9固定とし、実際に操作できる範囲にのみ制限を設ける(gif参照)
環境
Xcode 16.4
Swift 6
ニャンバープレイス 1.0.2
サンプルコード
※実際にアプリ内で動作しているコードとは異なります。
import SwiftUI
struct HomeView: View {
@State var lower = 0
@State var upper = 1
var body: some View {
NavigationStack {
List {
Section {
HStack {
Image(systemName: "square.grid.3x3.fill")
.resizable()
.scaledToFit()
.foregroundColor(Color.secondary)
.frame(width: 24, height: 24)
VStack {
Text("\(lower) 〜 \(upper)")
.monospacedDigit()
.foregroundColor(Color.secondary)
RangeSliderView(
lower: $lower,
upper: $upper,
minValue: 0,
maxValue: 3,
scaleLowerLimit: 0,
scaleUpperLimit: 9,
)
}
Image(systemName: "square.grid.3x3")
.resizable()
.scaledToFit()
.foregroundColor(Color.secondary)
.frame(width: 24, height: 24)
}
.padding(.vertical)
} header: {
Text("header")
} footer: {
Text("footer")
}
}
.listStyle(GroupedListStyle())
.navigationTitle("スライダー")
.navigationBarTitleDisplayMode(.inline)
}
}
}
#Preview {
HomeView()
}
import SwiftUI
struct RangeSliderView: View {
@Binding var lower: Int // 下限
@Binding var upper: Int // 上限
let minValue: Int // 操作できる下限
let maxValue: Int // 操作できる上限
let scaleLowerLimit: Int // 表示する下限
let scaleUpperLimit: Int // 表示する上限
var body: some View {
GeometryReader { geometry in
let sliderWidth = geometry.size.width
let stepWidth = sliderWidth / CGFloat(scaleUpperLimit - scaleLowerLimit)
ZStack(alignment: .leading) {
// 全体のバー
Rectangle()
.fill(Color.gray)
.frame(height: 4)
// 選択された範囲のバー
Rectangle()
.fill(Color.blue)
.frame(
width: CGFloat(upper - lower) * stepWidth,
height: 4,
)
.offset(x: CGFloat(lower - minValue) * stepWidth)
// Lower thumb
Circle()
.fill(Color.white)
.frame(width: 24, height: 24)
.offset(x: CGFloat(lower - minValue) * stepWidth - 12)
.shadow(radius: 2)
.gesture(
DragGesture()
.onChanged { value in
let position = value.location.x
let newStep = Int((position / stepWidth).rounded())
let clamped = max(minValue, min(newStep, upper - 1))
lower = clamped
}
)
// Upper thumb
Circle()
.fill(Color.white)
.frame(width: 24, height: 24)
.offset(x: CGFloat(upper - minValue) * stepWidth - 12)
.shadow(radius: 2)
.gesture(
DragGesture()
.onChanged { value in
let position = value.location.x
let newStep = Int((position / stepWidth).rounded())
let clamped = min(maxValue, max(newStep, lower + 1))
upper = clamped
}
)
}
}
.padding(.horizontal)
}
}
#Preview {
@Previewable @State var lower = 0
@Previewable @State var upper = 1
RangeSliderView(
lower: $lower,
upper: $upper,
minValue: 0,
maxValue: 3,
scaleLowerLimit: 0,
scaleUpperLimit: 9,
)
}
コード解説
VStack以外は割愛します
VStack {
Text("\(lower) 〜 \(upper)")
.monospacedDigit()
.foregroundColor(Color.secondary)
RangeSliderView(
lower: $lower,
upper: $upper,
minValue: 0,
maxValue: 3,
scaleLowerLimit: 0,
scaleUpperLimit: 9,
)
}
Text()はわかりやすく表示しているだけです。
RangeSliderView()に渡す引数について説明します。
lowerとupperには変更する値を渡します。
minValueに操作できる下限値、maxValueに操作できる上限値を渡します。
フリープラン想定でmaxValue = 3とします。
scaleLowerLimitは表示する下限値、scaleUpperLimitは表示する上限値を渡します。
GeometryReader { geometry in
let sliderWidth = geometry.size.width
let stepWidth = sliderWidth / CGFloat(scaleUpperLimit - scaleLowerLimit)
}
画面全体の幅を元にスライダーの幅を設定します。
スライダーの幅を分割して、ステップ幅を設定します。
// 全体のバー
Rectangle()
.fill(Color.gray)
.frame(height: 4)
全体のバーです。
標準のスライダーに合わせてグレーとします。
// 選択された範囲のバー
Rectangle()
.fill(Color.blue)
.frame(
width: CGFloat(upper - lower) * stepWidth,
height: 4,
)
.offset(x: CGFloat(lower - minValue) * stepWidth)
選択された範囲のバーです。
upperとlowerの差 * ステップ幅の分だけ青く表示します。
offset()を使用して、青く表示する範囲を右にずらします。
// Lower thumb
Circle()
.fill(Color.white)
.frame(width: 24, height: 24)
.offset(x: CGFloat(lower - minValue) * stepWidth - 12)
.shadow(radius: 2)
.gesture(
DragGesture()
.onChanged { value in
let position = value.location.x
let newStep = Int((position / stepWidth).rounded())
let clamped = max(minValue, min(newStep, upper - 1))
lower = clamped
}
)
標準のスライダーに寄せてCircle()を表示します。
offset()の中の-12は、Circle()の中央をスライダーの整数値に合わせるため指定しています。
画像で見るとわかりやすいです。見やすいようにスライダー部分を太くします。
.gesture()とDragGesture().onchangedを使用して、スライダーを操作したときに値を書き換えます。
座標位置とステップ幅から値を取得し、rounded()によって最も近い整数値に補正します。
lowerはupper - 1を超えないような処理も入れて、その値を最終的なlowerとして設定します。
// Upper thumb
Circle()
.fill(Color.white)
.frame(width: 24, height: 24)
.offset(x: CGFloat(upper - minValue) * stepWidth - 12)
.shadow(radius: 2)
.gesture(
DragGesture()
.onChanged { value in
let position = value.location.x
let newStep = Int((position / stepWidth).rounded())
let clamped = min(maxValue, max(newStep, lower + 1))
upper = clamped
}
)
やっていることはLower thumbと同様のため、説明は省略します。
備考
- ニャンバープレイスにおける配置パターン:ランダムでのブロック内の空白セルの数には偏りがあります
- 完全にランダムに空白セルを選択しているためで、0や9が選ばれる可能性は低いです
- 具体的には、空白セルの数に0〜9を指定したとき、0が選ばれる確率は1/512なのに対し、4が選ばれる確率は126/512です


