-
-
Save mattyoung/2f6999de06f4d76da640b267e1171e07 to your computer and use it in GitHub Desktop.
iOS Control Center Rubber Band Swift
This file contains hidden or 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
import SwiftUI | |
// had to move this out here due to RubberBandSwitch is now generic | |
enum Const { | |
static let shapeSize: CGSize = .init(width: 90.0, height: 190.0) | |
static let cornerRadius: CGFloat = 26.0 | |
} | |
struct RubberBandSwitch<Icon: View>: View { | |
// @State var value = 0.5 | |
@Binding var value: Double | |
var icon: (() -> Icon)? | |
var onLongPress: (() -> ())? | |
@State var hScale = 1.0 | |
@State var vScale = 1.0 | |
@State var anchor: UnitPoint = .center | |
@State var yOffset: CGFloat = 0.0 | |
@State var isTouching = false | |
@State var scale: CGFloat = 1.0 | |
@State var startValue: CGFloat = 0.0 | |
var body: some View { | |
ZStack { | |
wallpaper | |
slider | |
.clipShape(RoundedRectangle(cornerRadius: Const.cornerRadius, style: .continuous)) | |
.frame(width: Const.shapeSize.width, height: Const.shapeSize.height) | |
.scaleEffect(x: hScale, y: vScale, anchor: anchor) | |
.offset(x: 0, y: yOffset) | |
.gesture(onLongPress == nil ? | |
AnyGesture(dragNoLongPress.map { _ in () }) : | |
AnyGesture(dragWithLongPress.map { _ in () })) | |
} | |
.animation(isTouching ? .none : .smooth(duration: 0.5), value: vScale) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
} | |
var dragNoLongPress: some Gesture { | |
DragGesture(minimumDistance: 0) | |
.onChanged { drag in | |
withAnimation { | |
handleDrag(drag: drag) | |
} | |
} | |
.onEnded { _ in | |
isTouching = false | |
vScale = 1.0 | |
hScale = 1.0 | |
anchor = .center | |
yOffset = 0.0 | |
} | |
} | |
var dragWithLongPress: some Gesture { | |
LongPressGesture(maximumDistance: 0) | |
.onEnded { _ in onLongPress!() } | |
.exclusively(before: dragNoLongPress) | |
} | |
func handleDrag(drag: DragGesture.Value) { | |
if !isTouching { | |
startValue = value | |
} | |
isTouching = true | |
var value = startValue - (drag.translation.height / Const.shapeSize.height) | |
self.value = min(max(value, 0.0), 1.0) | |
var anchor: UnitPoint = .center | |
var yOffset: CGFloat = 0.0 | |
if value > 1 { | |
value = sqrt(sqrt(sqrt(value))) | |
yOffset = Const.shapeSize.height * (1 - value) / 2.0 | |
anchor = .bottom | |
} else if value < 0 { | |
value = sqrt(sqrt(sqrt(1 - value))) | |
yOffset = -Const.shapeSize.height * (1 - value) / 2.0 | |
anchor = .top | |
} else { | |
value = 1.0 | |
anchor = .center | |
} | |
vScale = value | |
hScale = 2 - sqrt(value) | |
self.yOffset = yOffset | |
self.anchor = anchor | |
} | |
@ViewBuilder | |
var wallpaper: some View { | |
Image(.wallpaper) | |
.resizable() | |
.padding(-40) | |
.ignoresSafeArea() | |
.blur(radius: 40) | |
} | |
@ViewBuilder | |
var slider: some View { | |
ZStack { | |
Rectangle() | |
.background(.ultraThickMaterial) | |
VStack { | |
Spacer() | |
.frame(minHeight: 0) | |
Rectangle() | |
.frame(width: Const.shapeSize.width, height: value * Const.shapeSize.height) | |
.foregroundStyle(Color(uiColor: .systemGray5)) | |
} | |
// if there is an icon | |
icon.map { icon in | |
VStack { | |
Spacer() | |
Spacer() | |
Spacer() | |
icon() | |
Spacer() | |
} | |
} | |
} | |
} | |
} | |
extension RubberBandSwitch { | |
init(value: Binding<Double>, icon: (() -> Icon)? = nil) where Icon == Never { | |
self._value = value | |
self.icon = icon | |
} | |
} | |
struct RubberBandSwitchPreviewHelper: View { | |
@State private var volume = 0.5 | |
@State private var showDetailView = false | |
func icon() -> some View { | |
SpeakerVolumeView(level: volume) | |
.frame(height: 30) | |
.foregroundColor(.primary) | |
} | |
var firstView: some View { | |
VStack { | |
HStack { | |
RubberBandSwitch(value: $volume, icon: icon) { | |
withAnimation { | |
showDetailView = true | |
} | |
} | |
RubberBandSwitch<Never>(value: $volume) | |
} | |
(Text("Volume ") + | |
Text(volume, format: .number.precision(.fractionLength(2))) | |
.font(.system(size: 40).monospacedDigit())) | |
.contentTransition(.numericText(value: volume)) | |
} | |
} | |
var body: some View { | |
if showDetailView { | |
DetailView(volume: $volume) | |
.onTapGesture { | |
withAnimation { | |
showDetailView = false | |
} | |
} | |
.transition(.move(edge: .leading)) | |
} else { | |
firstView | |
.transition(.move(edge: .trailing)) | |
} | |
} | |
} | |
struct DetailView: View { | |
@Binding var volume: Double | |
var body: some View { | |
VStack { | |
Spacer() | |
SpeakerVolumeView(level: volume) | |
.frame(height: 50) | |
.foregroundColor(.primary) | |
RubberBandSwitch(value: $volume) | |
Spacer() | |
} | |
} | |
} | |
#Preview { | |
RubberBandSwitchPreviewHelper() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment