Last active
February 12, 2023 07:44
-
-
Save HonmaMasaru/3b226491bd48687d6b3c252401a7a605 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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