Skip to content

Instantly share code, notes, and snippets.

@SintraWorks
Created August 22, 2022 09:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save SintraWorks/3059d705803fa1036c159798884eb7e9 to your computer and use it in GitHub Desktop.
Save SintraWorks/3059d705803fa1036c159798884eb7e9 to your computer and use it in GitHub Desktop.
Show a SwiftUI control from the bottom and implement avoidance
//: A UIKit based Playground for presenting user interface
import SwiftUI
import PlaygroundSupport
import Combine
struct ContentView: View {
@State var showPicker = false
@State var selection: String = "€1.500"
@State var pickerHeight: CGFloat = 0
var values = ["€1.500", "€3.000", "€6.000", "€9.000", "€10.000", "€11.000", "€12.000", "€13.000", "€14.000", "€15.000", "€16.000", "€17.000", "€18.000"]
@State var showMoreText = false
var body: some View {
VStack {
Group {
Spacer()
Text("Let's pick an amount")
.padding()
Button(action: {
withAnimation {
self.showPicker.toggle()
}
}) {
Text(selection)
.bottomWheelPickerAdaptive()
.padding()
}
}
Group {
Text("The chosen amount")
Text("is…")
Text(selection)
Text("Be kind")
Text("…and friendly,")
Text("really.")
}
if showMoreText {
Spacer()
Text("It works!")
}
}
.frame(minWidth: 300, minHeight: 600)
.overlay(BottomWheelPicker(show: $showPicker, selection: $selection, values: values), alignment: .bottom)
}
}
// WheelPicker
/// Enables wheel picker avoidance behavior
///
/// A view that has this modifiers attached will shift up to avoid the picker, if needed. (I.e. only if it would be obscured, or partly obscured by the picker.)
struct BottomWheelPickerAdaptive: ViewModifier {
/// The current frame of the picker. This will be either zero (when the picker is hidden), or the actul frame (when the picker is shown)
@State private var pickerFrame: CGRect = .zero
/// The offset to be applied to the adaptee's bottom.
@State private var offset: CGFloat = 0
/// The current frame of the adaptee.
@State private var adapteeFrame: CGRect = .zero
/// A padding to be apply to the offset to ensure the adaptee does not rest flush on the picker.
public var padding: CGFloat = 12
func body(content: Content) -> some View {
content
// padd the bottom by offset. This will effectively raise the adapting view when offset > 0.
.padding(.bottom, offset)
// subscribe to wheel picker frame changes
.onReceive(Publishers.wheelPickerFrame) { pickerFrame in
withAnimation {
self.pickerFrame = pickerFrame
guard pickerFrame.height > 0 else {
offset = 0
return
}
// The padding ensures the picker will not sit too closely below the adaptee, even if it wouldn't obscure any part of the adaptee.
//
// Additionally, only set an offset if the adaptee's bottom (extended by the amount of padding) will be obscured by the picker,
// i.e. only when the offset required for avoidance is greater than zero, because negative values mean the adaptee will remain sufficiently clear from the picker, without raising it.
offset = max(0, (adapteeFrame.maxY + padding) - pickerFrame.minY)
}
}
.background(
GeometryReader { geometry in
// Use a neutral view to allow us to obtain the frame of the content (= adaptee)
Color.clear
.preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
}
)
// Here we subscribe to changes to the frame of the adaptee.
.onPreferenceChange(FramePreferenceKey.self) { newFrame in
adapteeFrame = newFrame
}
}
}
extension View {
/// Provides bottom wheel picker avoidance behavior
func bottomWheelPickerAdaptive(padding: CGFloat = 12) -> some View {
ModifiedContent(content: self, modifier: BottomWheelPickerAdaptive(padding: padding))
}
}
extension Publishers {
/// Publishes the frame of the wheel picker whener it changes
///
/// It does this by subscribing to the `BottomWheelPicker`s `willShow` and `willHide` notifications.
static var wheelPickerFrame: AnyPublisher<CGRect, Never> {
// Create two publishers (one for willShow and one for willHide.
// We don't need frame info when hiding. Only when showing.
let willShow = NotificationCenter.default.publisher(for: BottomWheelPicker.willShowNotification).map { $0.frame }
let willHide = NotificationCenter.default.publisher(for: BottomWheelPicker.willHideNotification).map { _ in CGRect.zero }
// Merge the two publishers together into a single pipe
return MergeMany(willShow, willHide).eraseToAnyPublisher()
}
}
extension Notification {
/// Extends `Notificaton` with a shortcut to acces a frame from its user info dict.
var frame: CGRect {
userInfo?["frame"] as? CGRect ?? .zero
}
}
struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}
/// A view that shows a wheel picker and a done button, allowing the selection of values from the picker, and
/// dismissal when done. Whenever a value is picked, it is published.
/// Notifications are sent whenever the picker is about to be shown or hidden.
///
/// - Initializer Parameters:
/// - show: A binding that controls the showing or hiding the picker
/// - selection: A binding that manages the selected option from the collection of values
/// - values: The collection of values available to the picker
/// - hidesOnSelection: A flag to allow dismissing the picker as soon as a selection is made
struct BottomWheelPicker: View {
static let willShowNotification: NSNotification.Name = NSNotification.Name(rawValue: "PickerWillShow")
static let willHideNotification: NSNotification.Name = NSNotification.Name(rawValue: "PickerWillHide")
/// A binding that controls showing or hiding the picker
@Binding var show : Bool
/// A binding that manages the selected option from the collection of values
@Binding var selection: String
/// The collection of values available to the picker
var values: [String]
var body: some View {
WheelPicker(selection: self.$selection,
show: self.$show,
values: values,
dismissButtonTitle: "Done")
.fixedSize() // Ensures the WheelPicker is exactly as large as it needs to be, and no larger
.background(
// Use the geometry reader to extract the frame of the WheelPicker
GeometryReader { geometry in
Color.init(uiColor: .systemGray6)
.preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
})
.onPreferenceChange(FramePreferenceKey.self) { newFrame in
// Post a notification that we are showing or hiding, passing along the resulting frame info
let notificationName = show ? Self.willShowNotification : Self.willHideNotification
NotificationCenter.default.post(name: notificationName, object: self, userInfo: ["frame": newFrame])
}
// When not shown, hide us below the bottom of the screen.
.offset(y: self.show ? 0 : UIScreen.main.bounds.height)
}
}
struct WheelPicker: View {
@Binding var selection: String
@Binding private(set) var show: Bool
var values: [String]
var dismissButtonTitle: String
var body: some View {
VStack {
VStack {
Divider()
Spacer()
HStack {
Spacer()
Button(action: {
withAnimation {
self.show = false
}
}) {
HStack {
Spacer()
Text(dismissButtonTitle)
.padding(.horizontal, 16)
}
}
.fixedSize()
}
Divider()
}
.background(Color.init(uiColor: .secondarySystemGroupedBackground.withAlphaComponent(0.5)))
Picker(selection: $selection, label: Text("")) {
ForEach(values, id: \.self) {
Text("\($0)")
}
}
.pickerStyle(.wheel)
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment