Skip to content

Instantly share code, notes, and snippets.

@HonmaMasaru
Last active February 12, 2023 07:44
Show Gist options
  • Save HonmaMasaru/3b226491bd48687d6b3c252401a7a605 to your computer and use it in GitHub Desktop.
Save HonmaMasaru/3b226491bd48687d6b3c252401a7a605 to your computer and use it in GitHub Desktop.
//
// SeekBar.swift
//
// Created by Honma Masaru on 2023/02/12.
//
import SwiftUI
/// シークバー
struct SeekBar<Bound>: View where Bound: BinaryFloatingPoint, Bound.Stride: BinaryFloatingPoint {
/// ユーザーインタラクション
@Environment(\.isEnabled) var isEnabled
/// 値
@Binding private var value: Bound
/// 範囲
@Binding private var bounds: ClosedRange<Bound>
/// ビューのサイズ
@State private var viewSize: CGSize = .zero
/// 現在の位置
@State private var currentPos: CGFloat = 0
/// ドラッグ中
@State private var isDragging: Bool = false
/// 背景色
private var baseColorSet: [Bool: Color] = [true: .gray, false: .gray.opacity(0.6)]
private var baseColor: Color { baseColorSet[isEnabled]! }
/// 経過色
private var tintColorSet: [Bool: Color] = [true: .red, false: .red.opacity(0.6)]
private var tintColor: Color { tintColorSet[isEnabled]! }
/// ハンドル色
private var handleColorSet: [Bool: Color] = [true: .red, false: .red.opacity(0.6)]
private var handleColor: Color { handleColorSet[isEnabled]! }
/// ハンドルとバーの比率
private var barAscpect: CGFloat = 5 / 8
/// バーの高さ
private var barHeight: CGFloat {
viewSize.height * barAscpect
}
/// ハンドルの位置
private var handlePos: CGFloat {
currentPos - (viewSize.height / 2)
}
/// 開始時のアクション
private var changedAction: (() -> Void)?
/// 完了時のアクション
private var endedAction: (() -> Void)?
// MARK: -
/// 初期化
/// - Parameters:
/// - value: 値
/// - bounds: 範囲
init(value: Binding<Bound>, in bounds: Binding<ClosedRange<Bound>>) {
_value = value
_bounds = bounds
}
/// ビュー
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Bar(color: baseColor, width: viewSize.width)
Bar(color: tintColor, width: currentPos)
if isEnabled {
Handle.gesture(drag)
} else {
Handle
}
}
.onAppear {
viewSize = geometry.size
setCurrentPos(value)
}
.onChange(of: value) {
setCurrentPos($0)
}
}
}
// MARK: - 表示関連
/// ハンドルの位置を設定
/// - Parameter value: 値
@MainActor
private func setCurrentPos(_ value: Bound) {
currentPos = viewSize.width * CGFloat(bounds.rate(value))
}
// MARK: - モディファー
/// 変更時のアクション
/// - Parameter action: アクション
func onChanged(action: @escaping () -> Void) -> Self {
var view = self
view.changedAction = action
return view
}
/// 完了時のアクション
/// - Parameter action: アクション
func onEnded(action: @escaping () -> Void) -> Self {
var view = self
view.endedAction = action
return view
}
/// 背景色の設定
/// - Parameters:
/// - color: カラー
/// - opacity: 透過率
func baseColor(_ color: Color, opacity: Double = 0.6) -> Self {
var view = self
view.baseColorSet[true] = color
view.baseColorSet[false] = color.opacity(opacity)
return view
}
/// 経過色の設定
/// - Parameters:
/// - color: カラー
/// - opacity: 透過率
func tintColor(_ color: Color, opacity: Double = 0.6) -> Self {
var view = self
view.tintColorSet[true] = color
view.tintColorSet[false] = color.opacity(opacity)
return view
}
/// ハンドル色の設定
/// - Parameters:
/// - color: カラー
/// - opacity: 透過率
func handleColor(_ color: Color, opacity: Double = 0.6) -> Self {
var view = self
view.handleColorSet[true] = color
view.handleColorSet[false] = color.opacity(opacity)
return view
}
/// 背景色 (有効)
/// - Parameter color: カラー
func enabledBaseColor(_ color: Color) -> Self {
var view = self
view.baseColorSet[true] = color
return view
}
/// 背景色 (無効)
/// - Parameter color: カラー
func disabledBaseColor(_ color: Color) -> Self {
var view = self
view.baseColorSet[false] = color
return view
}
/// 経過色 (有効)
/// - Parameter color: カラー
func enabledTintColor(_ color: Color) -> Self {
var view = self
view.tintColorSet[true] = color
return view
}
/// 経過色 (無効)
/// - Parameter color: カラー
func disabledTintColor(_ color: Color) -> Self {
var view = self
view.tintColorSet[false] = color
return view
}
/// ハンドル色 (有効)
/// - Parameter color: カラー
func enabledHandleColor(_ color: Color) -> Self {
var view = self
view.handleColorSet[true] = color
return view
}
/// ハンドル色 (無効)
/// - Parameter color: カラー
func disabledHandleColor(_ color: Color) -> Self {
var view = self
view.handleColorSet[false] = color
return view
}
/// ハンドルとバーの比率
func barAscpect(_ ascpect: CGFloat) -> Self {
var view = self
view.barAscpect = ascpect
return view
}
// MARK: - ビュー
/// ハンドル
private var Handle: some View {
Circle()
.foregroundColor(handleColor)
.frame(width: viewSize.height, height: viewSize.height)
.offset(.init(width: handlePos, height: 0))
}
/// バー
/// - Parameters:
/// - color: 色
/// - width: 幅
/// - Returns: バー
private func Bar(color: Color, width: CGFloat) -> some View {
RoundedRectangle(cornerRadius: barHeight)
.foregroundColor(color)
.frame(width: width, height: barHeight)
}
// MARK: - ドラッグ
/// 値の変更
/// - Parameter value: 値
@MainActor
private func set(value: Bound) {
if bounds.contains(value) {
self.value = value
} else if value < bounds.lowerBound {
self.value = bounds.lowerBound
} else if value > bounds.upperBound {
self.value = bounds.upperBound
}
}
/// ドラッグのハンドラ
private var drag: some Gesture {
DragGesture()
.onChanged {
if !isDragging { changedAction?() }
isDragging = true
dragHandler($0)
}
.onEnded {
isDragging = false
dragHandler($0)
endedAction?()
}
}
/// ドラッグ処理
/// - Parameter value: ハンドルの位置
private func dragHandler(_ value: DragGesture.Value) {
if (0...viewSize.width).contains(value.location.x) {
Task {
await set(value: bounds.length * Bound(value.location.x / viewSize.width))
}
}
}
}
// MARK: - ClosedRange Extension
private extension ClosedRange where Bound: BinaryFloatingPoint {
/// 範囲
var length: Bound {
upperBound - lowerBound
}
/// 割合
func rate(_ value: Bound) -> Bound {
(value - lowerBound) / length
}
}
// MARK: - プレビュー
#if DEBUG
struct SeekBar_Previews: PreviewProvider {
@State static var value: TimeInterval = 40
@State static var bounds: ClosedRange<TimeInterval> = 0...100
static var previews: some View {
VStack {
SeekBar(value: $value, in: $bounds)
.onChanged {
// 変更中
}
.onEnded {
// 終了
}
.frame(width: 300, height: 10)
.disabled(false)
Text("value: \(value)")
}
.padding()
.previewDisplayName("Seek Bar")
.previewLayout(.sizeThatFits)
VStack {
SeekBar(value: $value, in: $bounds)
.barAscpect(8 / 10)
.baseColor(.blue)
.tintColor(.green)
.handleColor(.brown)
.frame(width: 300, height: 10)
.disabled(false)
Text("value: \(value)")
}
.padding()
.previewDisplayName("Seek Bar (Design 1)")
.previewLayout(.sizeThatFits)
VStack {
SeekBar(value: $value, in: $bounds)
.enabledBaseColor(.blue)
.disabledBaseColor(.gray)
.enabledTintColor(.green)
.disabledTintColor(.black)
.enabledHandleColor(.indigo)
.disabledHandleColor(.orange)
.frame(width: 300, height: 10)
.disabled(false)
Text("value: \(value)")
}
.padding()
.previewDisplayName("Seek Bar (Design 2)")
.previewLayout(.sizeThatFits)
}
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment