0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【SwiftUI】範囲指定のスライダー

0
Posted at

はじめに

iOSアプリ「ニャンバープレイス」では、ルールに「カスタム」を選択し、配置パターンを「ランダム」にすると、空白セルの数を範囲指定のスライダーで調整できます。

rangeslider.gif

標準ではこのようなスライダーが用意されていないようだったので、勉強がてら自分で作成することにしました。

要件

  • 範囲指定できるスライダーを実装する
  • 整数値のみをとる
  • 下限値と上限値を同じ値にすると"つまみ"が重なって操作しづらくなるため、下限値は上限値-1を最大値、上限値は下限値+1を最小値とする
  • 範囲は0から9までとするが、ニャンバープレイスではフリープランだと範囲は0〜3までに制限している。ただし、単純に最大値を3と9とで切り替えると、パッと見たときの印象が変わって都合が悪い。そのため、あくまで右端は9固定とし、実際に操作できる範囲にのみ制限を設ける(gif参照)

環境

Xcode 16.4
Swift 6
ニャンバープレイス 1.0.2

サンプルコード

※実際にアプリ内で動作しているコードとは異なります。

HomeView.swift
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()
}
RangeSliderView.swift
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,
    )
}

rangeslider2.gif

コード解説

VStack以外は割愛します

HomeView.swift
    VStack {
        Text("\(lower)\(upper)")
            .monospacedDigit()
            .foregroundColor(Color.secondary)
        
        RangeSliderView(
            lower: $lower,
            upper: $upper,
            minValue: 0,
            maxValue: 3,
            scaleLowerLimit: 0,
            scaleUpperLimit: 9,
        )
    }

Text()はわかりやすく表示しているだけです。

RangeSliderView()に渡す引数について説明します。
lowerupperには変更する値を渡します。
minValueに操作できる下限値、maxValueに操作できる上限値を渡します。
フリープラン想定でmaxValue = 3とします。
scaleLowerLimitは表示する下限値、scaleUpperLimitは表示する上限値を渡します。

RangeSliderView.swift(1)
        GeometryReader { geometry in
            let sliderWidth = geometry.size.width
            let stepWidth = sliderWidth / CGFloat(scaleUpperLimit - scaleLowerLimit)
        }

画面全体の幅を元にスライダーの幅を設定します。
スライダーの幅を分割して、ステップ幅を設定します。

RangeSliderView.swift(2)
    // 全体のバー
    Rectangle()
        .fill(Color.gray)
        .frame(height: 4)

全体のバーです。
標準のスライダーに合わせてグレーとします。

RangeSliderView.swift(3)
    // 選択された範囲のバー
    Rectangle()
        .fill(Color.blue)
        .frame(
            width: CGFloat(upper - lower) * stepWidth,
            height: 4,
        )
        .offset(x: CGFloat(lower - minValue) * stepWidth)

選択された範囲のバーです。
upperlowerの差 * ステップ幅の分だけ青く表示します。
offset()を使用して、青く表示する範囲を右にずらします。

RangeSliderView.swift(4)
    // 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()の中央をスライダーの整数値に合わせるため指定しています。
画像で見るとわかりやすいです。見やすいようにスライダー部分を太くします。

offset.png
-12ありの方が直感的と思います。

.gesture()DragGesture().onchangedを使用して、スライダーを操作したときに値を書き換えます。
座標位置とステップ幅から値を取得し、rounded()によって最も近い整数値に補正します。
lowerupper - 1を超えないような処理も入れて、その値を最終的なlowerとして設定します。

RangeSliderView.swift(5)
    // 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です
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?