Show a SwiftUI control from the bottom and implement avoidance
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
//: 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