SwiftUI: How to add onHover to all buttons - swiftui

This is for macOS, how to add onHover in the extension so that it applies to every Button in my app?
Button("Hello World") {
print("Hello World")
}
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
extension Button {
//
}

You can "override" the default implementations with a "Project" version.
//Name `struct` Button
struct Button: View{
let title: LocalizedStringKey
let action: () -> Void
init(_ title: LocalizedStringKey, action: #escaping () -> Void) {
self.title = title
self.action = action
}
var body: some View{
//Uses SwiftUI version of button
SwiftUI.Button(title, action: action)
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
}
}
The example above will replace all the instances of
public init(_ titleKey: LocalizedStringKey, action: #escaping () -> Void)

Related

SwiftUI: In SplitView, how can I detect if the master view is visible?

When SwiftUI creates a SplitView, it adds a toolbar button that hides/shows the Master view. How can I detect this change so that I can resize the font in the detail screen and use all the space optimally?
I've tried using .onChange with geometry but can't seem to get that to work.
If you're using iOS 16 you can use NavigationSplitView with NavigationSplitViewVisibility
Example:
struct MySplitView: View {
#State private var columnVisibility: NavigationSplitViewVisibility = .all
var bothAreShown: Bool { columnVisibility != .detailOnly }
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Text("Master Column")
} detail: {
Text("Detail Column")
Text(bothAreShown ? "Both are shown" : "Just detail shown")
}
}
}
After thinkering for a while on this I got to this solution:
struct ContentView: View {
#State var isOpen = true
var body: some View {
NavigationView {
VStack{
Text("Primary")
.onUIKitAppear {
isOpen.toggle()
}
.onAppear{
print("hello")
isOpen.toggle()
}
.onDisappear{
isOpen.toggle()
print("hello: bye")
}
.navigationTitle("options")
}
Text("Secondary").font(isOpen ? .body : .title)
}.navigationViewStyle(.columns)
}
}
The onUIKitAppear is a custom extension suggested by apple to be only executed once the view has been presented to the user https://developer.apple.com/forums/thread/655338?page=2
struct UIKitAppear: UIViewControllerRepresentable {
let action: () -> Void
func makeUIViewController(context: Context) -> UIAppearViewController {
let vc = UIAppearViewController()
vc.delegate = context.coordinator
return vc
}
func makeCoordinator() -> Coordinator {
Coordinator(action: self.action)
}
func updateUIViewController(_ controller: UIAppearViewController, context: Context) {}
class Coordinator: ActionRepresentable {
var action: () -> Void
init(action: #escaping () -> Void) {
self.action = action
}
func remoteAction() {
action()
}
}
}
protocol ActionRepresentable: AnyObject {
func remoteAction()
}
class UIAppearViewController: UIViewController {
weak var delegate: ActionRepresentable?
var savedView: UIView?
override func viewDidLoad() {
self.savedView = UILabel()
if let _view = self.savedView {
view.addSubview(_view)
}
}
override func viewDidAppear(_ animated: Bool) {
delegate?.remoteAction()
}
override func viewDidDisappear(_ animated: Bool) {
view.removeFromSuperview()
savedView?.removeFromSuperview()
}
}
public extension View {
func onUIKitAppear(_ perform: #escaping () -> Void) -> some View {
self.background(UIKitAppear(action: perform))
}
}

SwiftUI Search Bar in line with navigation bar

Does anyone have working Swiftui code that will produce a search bar in the navigation bar that is inline with the back button? As if it is a toolbar item.
Currently I have code that will produce a search bar below the navigation back button but would like it in line like the picture attached shows (where the "hi" is):
I am using code that I found in an example:
var body: some View {
let shopList = genShopList(receiptList: allReceipts)
VStack{
}
.navigationBarSearch(self.$searchInput)
}
public extension View {
public func navigationBarSearch(_ searchText: Binding<String>) -> some View {
return overlay(SearchBar(text: searchText)
.frame(width: 0, height: 0))
}
}
fileprivate struct SearchBar: UIViewControllerRepresentable {
#Binding
var text: String
init(text: Binding<String>) {
self._text = text
}
func makeUIViewController(context: Context) -> SearchBarWrapperController {
return SearchBarWrapperController()
}
func updateUIViewController(_ controller: SearchBarWrapperController, context: Context) {
controller.searchController = context.coordinator.searchController
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
class Coordinator: NSObject, UISearchResultsUpdating {
#Binding
var text: String
let searchController: UISearchController
private var subscription: AnyCancellable?
init(text: Binding<String>) {
self._text = text
self.searchController = UISearchController(searchResultsController: nil)
super.init()
searchController.searchResultsUpdater = self
searchController.hidesNavigationBarDuringPresentation = true
searchController.obscuresBackgroundDuringPresentation = false
self.searchController.searchBar.text = self.text
self.subscription = self.text.publisher.sink { _ in
self.searchController.searchBar.text = self.text
}
}
deinit {
self.subscription?.cancel()
}
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text else { return }
self.text = text
}
}
class SearchBarWrapperController: UIViewController {
var searchController: UISearchController? {
didSet {
self.parent?.navigationItem.searchController = searchController
}
}
override func viewWillAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
override func viewDidAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
}
}
If anyone has a solution to this problem that would be greatly appreciated! I know that in IoS 15 they are bringing out .searchable but looking for something that will work for earlier versions too.
You can put any control in the position you want by using the .toolbar modifier (iOS 14+) and an item with .principal placement, e.g.:
var body: some View {
VStack {
// rest of view
}
.toolbar {
ToolbarItem(placement: .principal) {
MySearchField(text: $searchText)
}
}
}
A couple of things to note:
The principal position overrides an inline navigation title, either when it's set with .navigationBarTitleDisplayMode(.inline) or when you have a large title and scroll up the page.
It's possible that your custom view expands horizontally so much that the back button loses any text component. Here, I used a TextField to illustrate the point:
You might be able to mitigate for that by assigning a maximum width with .frame(maxWidth:), but at the very least it's something to be aware of.

How to add a custom tap handler on a custom SwiftUI View?

List {
ItemView(item: item)
.myCustomTapHandler {
print("ItemView was tapped, triggered from List!")
}
}
}
struct ItemView: View {
var body: some View {
VStack {
Button(action: {
// this should fire myCustomTapHandler
}) {
Text("Hello world")
}
}
}
I have a custom ItemView with a simple button. I want to re create the same trailing closure syntax as .onTapGesture, with only triggering when you tap the Button. This will be named .myCustomTapHandler How to do this in SwiftUI?
What you are describing (The dot) is a ViewModifier
struct MyItemListView: View {
var body: some View {
List {
//Using a ViewModifier you make the whole View tappable not just the button
MyItemView(item: 2)
.myCustomTapHandler{
print("ItemView has custom modifier that makes the whole ItemView tappable")
}
}
}
}
struct MyCustomTapHandler: ViewModifier {
var myCustomTapHandler: () -> Void
func body(content: Content) -> some View {
content
//Add the onTap to the whole View
.onTapGesture {
myCustomTapHandler()
}
}
}
extension View {
func myCustomTapHandler(myCustomTapHandler: #escaping () -> Void) -> some View {
modifier(MyCustomTapHandler(myCustomTapHandler: myCustomTapHandler))
}
}
The ViewModifier affects the entire View not just the Button.
But, unless you are doing something else it is just an onTapGesture.
This is likely not the best solution because with the Button in the ItemView you will have inconsistent results.
Sometimes the Button will get the tap and sometimes the ViewModifier will get the tap and given that the View is in a List it will likely make the whole tapping confusing because the List has properties that make the whole row tappable anyway vs just the Text of the `Button
If you want the Button to perform an action that is defined in the ListView you can pass it as a parameter.
This will likely give you the best results.
struct MyItemListView: View {
var body: some View {
VStack {
//This passes the custom action to the button
MyItemView(item: 1){
print("Button needs to be tapped to trigger this")
}
}
}
}
struct MyItemView: View {
let item: Int
var myCustomTapHandler: () -> Void
var body: some View {
VStack {
Button(action: {
myCustomTapHandler()
}) {
Text("Hello world")
}
}
}
}
You can add like this.
struct ItemView: View {
private var action: (() -> Void)? = nil
var body: some View {
VStack {
Button(action: {
action?()
}) {
Text("Hello world")
}
}
}
func myCustomTapHandler(onAction: #escaping () -> Void) -> Self {
var view = self
view.action = onAction
return view
}
}
Usage
struct ContentView: View {
var body: some View {
ItemView()
.myCustomTapHandler {
print("Hello word")
}
}
}
Another way is...
I don't think with dot property you will get action. You need closure inside the ItemView.
Like this
struct ItemView: View {
var action: () -> Void
var body: some View {
VStack {
Button(action: action) {
Text("Hello world")
}
}
}
}
Usage
List {
ItemView {
// Here you will get action
print("ItemView was tapped, triggered from List!")
}
}

Detect touch outside Button in SwiftUI

I have a reset button that asks for confirmation first. I would like to set isSure to false is the user touches outside the component.
Can I do this from the Button component?
Here is my button:
struct ResetButton: View {
var onConfirmPress: () -> Void;
#State private var isSure: Bool = false;
var body: some View {
Button(action: {
if (self.isSure) {
self.onConfirmPress();
self.isSure.toggle();
} else {
self.isSure.toggle();
}
}) {
Text(self.isSure ? "Are you sure?" : "Reset")
}
}
}
here is one way to do it:
struct ContentView: View {
var onConfirmPress: () -> Void
#State private var isSure: Bool = false
var body: some View {
GeometryReader { geometry in
ZStack {
// a transparent rectangle under everything
Rectangle()
.frame(width: geometry.size.width, height: geometry.size.height)
.opacity(0.001) // <--- important
.layoutPriority(-1)
.onTapGesture {
self.isSure = false
print("---> onTapGesture self.isSure : \(self.isSure)")
}
Button(action: {
if (self.isSure) {
self.onConfirmPress()
}
self.isSure.toggle()
}) {
Text(self.isSure ? "Are you sure?" : "Reset").padding(10).border(Color.black)
}
}
}
}
}
Basically, we have some view, and we want a tap on its background to do something - meaning, we want to add a huge background that registers a tap. Note that .background is only offered the size of the main view, but can always set an explicit different size! If you know your size that's great, otherwise UIScreen could work...
This is hacky but seems to work!
extension View {
#ViewBuilder
private func onTapBackgroundContent(enabled: Bool, _ action: #escaping () -> Void) -> some View {
if enabled {
Color.clear
.frame(width: UIScreen.main.bounds.width * 2, height: UIScreen.main.bounds.height * 2)
.contentShape(Rectangle())
.onTapGesture(perform: action)
}
}
func onTapBackground(enabled: Bool, _ action: #escaping () -> Void) -> some View {
background(
onTapBackgroundContent(enabled: enabled, action)
)
}
}
Usage:
SomeView()
.onTapBackground(enabled: isShowingAlert) {
isShowingAlert = false
}
This can be easily changed to take a binding:
func onTapBackground(set value: Binding<Bool>) -> some View {
background(
onTapBackgroundContent(enabled: value.wrappedValue) { value.wrappedValue = false }
)
}
// later...
SomeView()
.onTapBackground(set: $isShowingAlert)

Is it possible to create an alert with more than 2 buttons in SwiftUI?

I need to create an alert with 3 buttons, but it looks like SwiftUI only gives us two options right now: one button or two buttons. I know that with UIKit, 3 buttons are achievable, but I can't seem to find a workaround in the latest version of SwiftUI to do this. Below is my code where I'm using only a primary and secondary button.
Button(action: {
self.showAlert = true
}){
Text("press me")
}
.alert(isPresented: self.$showAlert){
Alert(title: Text("select option"), message: Text("pls help"), primaryButton: Alert.Button.default(Text("yes"), action: {
print("yes clicked")
}), secondaryButton: Alert.Button.cancel(Text("no"), action: {
print("no clicked")
})
)
}
This is now possible on iOS 15/macOS 12 with a new version of the alert modifier: alert(_:isPresented:presenting:actions:).
It works a bit differently because the Alert struct isn't used anymore; you use regular SwiftUI Buttons instead. Add a ButtonRole to indicate which buttons perform the "cancel" action or "destructive" actions. Add the .keyboardShortcut(.defaultAction) modifier to a button to indicate it performs the principal action.
For example:
MyView()
.alert("Test", isPresented: $presentingAlert) {
Button("one", action: {})
Button("two", action: {}).keyboardShortcut(.defaultAction)
Button("three", role: .destructive, action: {})
Button("four", role: .cancel, action: {})
}
Creates the following alert:
.actionSheet, .sheet, and .popover are options to provide custom alerts. Consider this sample:
import SwiftUI
struct ContentView: View {
#State private var showModal = false
#State private var cnt = 0
var body: some View {
VStack {
Text("Counter is \(cnt)")
Button("Show alert") {
self.showModal = true
}
}
.sheet(isPresented: $showModal,
onDismiss: {
print(self.showModal)}) {
CustomAlert(message: "This is Modal view",
titlesAndActions: [("OK", {}),
("Increase", { self.cnt += 1 }),
("Cancel", nil)])
}
}
}
struct CustomAlert: View {
#Environment(\.presentationMode) var presentation
let message: String
let titlesAndActions: [(title: String, action: (() -> Void)?)] // = [.default(Text("OK"))]
var body: some View {
VStack {
Text(message)
Divider().padding([.leading, .trailing], 40)
HStack {
ForEach(titlesAndActions.indices, id: \.self) { i in
Button(self.titlesAndActions[i].title) {
(self.titlesAndActions[i].action ?? {})()
self.presentation.wrappedValue.dismiss()
}
.padding()
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If you want to be compatible with previous versions of iOS 15, consider to use actionSheet like this:
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(title: Text("Title"), message: Text("Choose one of this three:"), buttons: [
.default(Text("First")) { },
.default(Text("Second")) { },
.default(Text("Third")) { },
.cancel()
])
}
example below requires iOS 14.0 and available for both iPad and iPhone.
i also beleive this code will require minimum modifications to support iOS 13.0 as well.
(xcode project with usage example is available here)
import SwiftUI
public extension View {
func alert(if condition: Binding<Bool>,
title: String = "",
text: String = "",
buttons: [Alert.Option] = [.cancel("OK")]) -> some View {
let alert: AlertView = .init(condition, title, text, buttons)
return overlay(condition.wrappedValue ? alert : nil)
}
}
private struct AlertView : View {
#Binding var active: Bool
private let title: String
private let text: String
private let buttons: [Alert.Option]
private let cancel: Alert.Option?
init(_ active: Binding<Bool>, _ title: String, _ text: String, _ buttons: [Alert.Option]) {
self._active = active
self.title = title
self.text = text
self.buttons = buttons.filter { $0.role != .cancel }
self.cancel = buttons.first { $0.role == .cancel } ?? (buttons.isEmpty ? .cancel("OK") : nil)
}
var body: some View {
VStack() {
if title.count > 0 { Text(title).fontWeight(.semibold).padding([.top, .bottom], 5) }
if text.count > 0 { Text(text).fixedSize(horizontal: false, vertical: true) }
ForEach(buttons, id: \.label) { Divider(); button(for: $0) }
if let cancel { Divider(); button(for: cancel) }
}.padding()
.frame(maxWidth: width)
.background(Color(.secondarySystemBackground))
.cornerRadius(20)
.shadow(color: Color(.black), radius: 5)
.screenCover()
.onTapGesture { if cancellable { active = false } }
}
private func button(for option: Alert.Option) -> some View {
HStack {
Spacer()
Text(option.label)
.foregroundColor(option.role == .destructive ? Color(.red) : Color(.link))
.fontWeight(option.role == .cancel ? .semibold : .regular)
.padding([.top, .bottom], 5)
Spacer()
}.background(Color(.secondarySystemBackground))
.onTapGesture { if option.role != .cancel { option.action() }; active = false }
}
private var width: CGFloat { min((UIScreen.main.bounds.width * 0.8), 500) }
private var cancellable: Bool { cancel != nil }
}
extension Alert {
public final class Option {
public let label: String
public let role: Role
public let action: () -> Void
public init(_ label: String, role: Role = .regular, action: #escaping () -> Void = { }) {
self.label = label
self.role = role
self.action = action
}
public static func cancel(_ label: String) -> Option { .init(label, role: .cancel) }
}
}
public extension SwiftUI.Alert.Option { enum Role { case regular; case cancel; case destructive } }
public extension View {
func screenCover(_ color: Color = .init(UIColor.systemBackground), opacity: Double = 0.5) -> some View {
color.opacity(opacity)
.ignoresSafeArea()
.overlay(self)
}
}