SwiftUI: Button without Label/String in the initializer but with ButtonStyle - ios

SwiftUI has a few Button initializers, but all of them require either a String or some View as the parameter alongside with the action.
However, the button's appearance can also be customized with the help of ButtonStyles which can add custom views to it.
Let's consider a Copy button with the following icon:
The style I've made for the button looks as follows:
struct CopyButtonStyle: ButtonStyle {
init() {}
func makeBody(configuration: Configuration) -> some View {
let copyIconSize: CGFloat = 24
return Image(systemName: "doc.on.doc")
.renderingMode(.template)
.resizable()
.frame(width: copyIconSize, height: copyIconSize)
.accessibilityIdentifier("copy_button")
.opacity(configuration.isPressed ? 0.5 : 1)
}
}
It works perfectly, however, I have to initialize the Button with an empty string at call site:
Button("") {
print("copy")
}
.buttonStyle(CopyButtonStyle())
So, the question is how can I get rid of the empty string in the button's initialization parameter?
Potential Solution
I was able to create a simple extension that accomplishes the job I need:
import SwiftUI
extension Button where Label == Text {
init(_ action: #escaping () -> Void) {
self.init("", action: action)
}
}
Call site:
Button() { // Note: no initializer parameter
print("copy")
}
.buttonStyle(CopyButtonStyle())
But curious, whether I'm using the Button struct incorrectly and there is already a use-case for that, so that I can get rid of this extension.

An easier way than making a ButtonStyle configuration is to pass in the label directly:
Button {
print("copy")
} label: {
Label("Copy", systemImage: "doc.on.doc")
.labelStyle(.iconOnly)
}
This also comes with some benefits:
By default, the button is blue to indicate it can be tapped
No weird stretching of the image that you currently have
No need to implement how the opacity changes when pressed
You could also refactor this into its own view:
struct CopyButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Label("Copy", systemImage: "doc.on.doc")
.labelStyle(.iconOnly)
}
}
}
Called like so:
CopyButton {
print("copy")
}
Which looks much cleaner overall.

Here is a right way for what you are trying to do, you do not need make a new ButtonStyle for each kind of Button, you can create just one and reuse it for any other Buttons you want. Also I solved your Image stretching issue with .scaledToFit().
struct CustomButtonView: View {
let imageString: String
let size: CGFloat
let identifier: String
let action: (() -> Void)?
init(imageString: String, size: CGFloat = 24.0, identifier: String = String(), action: (() -> Void)? = nil) {
self.imageString = imageString
self.size = size
self.identifier = identifier
self.action = action
}
var body: some View {
return Button(action: { action?() } , label: {
Image(systemName: imageString)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.accessibilityIdentifier(identifier)
})
.buttonStyle(CustomButtonStyle())
}
}
struct CustomButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
return configuration.label
.opacity(configuration.isPressed ? 0.5 : 1.0)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
}
}
use case:
struct ContentView: View {
var body: some View {
CustomButtonView(imageString: "doc.on.doc", identifier: "copy_button", action: { print("copy") })
}
}

You can use EmptyView for label, like
Button(action: { // Note: no initializer parameter
print("copy")
}, label: { EmptyView() })
.buttonStyle(CopyButtonStyle())
but wrapping it in custom button type (like shown in other answer) is more preferable from re-use and code readability point of view.

Related

How to make SwiftUI button label uppercase

Is it possible to transform that text of a label in a SwiftUI button to uppercase using a style?
struct UppercaseButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.makeUppercase() // ?
}
}
struct UppercaseButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.textCase(.uppercase) // <- here
}
}
usage:
struct ContentView: View {
var body: some View {
Button("test", action: {})
.buttonStyle(UppercaseButtonStyle()) // <= here
}
}
The textCase modifier will work directly on your button, e.g.:
Button("test", action: {})
.textCase(.uppercase)
However, if you want to wrap this up in a style, it's better to use a PrimitiveButtonStyle, as this comes with a Configuration object that can be passed into the Button initializer.
struct UppercaseButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: Configuration) -> some View {
Button(configuration)
.textCase(.uppercase)
}
}
// bonus points - add a shorthand description to match built-in styles
extension PrimitiveButtonStyle where Self == UppercaseButtonStyle {
static var uppercase = UppercaseButtonStyle()
}
// usage
Button("Test") { }
.buttonStyle(.uppercase)
This means that you don't need to worry about any other type of configuration on the button, and your style should be able to play nicely with others, e.g.:
Button("Test", role: .destructive) { }
.buttonStyle(.borderedProminent)
.buttonStyle(.uppercase)

SwiftUI: Custom button does not recognize touch with clear background and buttonStyle

I stumbled upon a weird behaviour for Buttons in SwiftUI in combination with a custom ButtonStyle.
My target was to create a custom ButtonStyle with some kind of 'push-back animation'. I used the following setup for this:
struct CustomButton<Content: View>: View {
private let content: () -> Content
init(content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack {
Button(action: { ... }) {
content()
}
.buttonStyle(PushBackButtonStyle(pushBackScale: 0.9))
}
}
}
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
// Preview
struct Playground_Previews: PreviewProvider {
static var previews: some View {
CustomButton {
VStack(spacing: 10) {
HStack {
Text("Button Text").background(Color.orange)
}
Divider()
HStack {
Text("Detail Text").background(Color.orange)
}
}
}
.background(Color.red)
}
}
When I now try to touch on this button outside of the Text view, nothing will happen. No animation will be visible and the action block will not be called.
What I found out so far:
when you remove the .buttonStyle(...) it does work as expected (no custom animation of course)
or when you set a .background(Color.red)) on the VStack in the CustomButton it does also work as expected in combination with the .buttonStyle(...)
The question now is if anybody have a better idea of how to properly work around this issue or how to fix it?
Just add hit testing content shape in your custom button style, like below
Tested with Xcode 11.4 / iOS 13.4
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.contentShape(Rectangle()) // << fix !!
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
Simply use a .frame and it should work.
To make it easily testable I have rewritten it like this:
struct CustomButton: View {
var body: some View {
Button(action: { }) {
VStack(spacing: 10) {
HStack {
Text("Button Text").background(Color.orange)
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.orange)
}
Divider()
HStack {
Text("Detail Text").background(Color.orange)
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.orange)
}
}
}
.buttonStyle(PushBackButtonStyle(pushBackScale: 0.9))
}
}
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
I hope I could help. :-)
#Edit With video.

How to check if a view is displayed on the screen? (Swift 5 and SwiftUI)

I have a view like below. I want to find out if it is the view which is displayed on the screen. Is there a function to achieve this?
struct TestView: View {
var body: some View {
Text("Test View")
}
}
You could use onAppear on any kind of view that conforms to View protocol.
struct TestView: View {
#State var isViewDisplayed = false
var body: some View {
Text("Test View")
.onAppear {
self.isViewDisplayed = true
}
.onDisappear {
self.isViewDisplayed = false
}
}
func someFunction() {
if isViewDisplayed {
print("View is displayed.")
} else {
print("View is not displayed.")
}
}
}
PS: Although this solution covers most cases, it has many edge cases that has not been covered. I'll be updating this answer when Apple releases a better solution for this requirement.
You can check the position of view in global scope using GeometryReader and GeometryProxy.
struct CustomButton: View {
var body: some View {
GeometryReader { geometry in
VStack {
Button(action: {
}) {
Text("Custom Button")
.font(.body)
.fontWeight(.bold)
.foregroundColor(Color.white)
}
.background(Color.blue)
}.navigationBarItems(trailing: self.isButtonHidden(geometry) ?
HStack {
Button(action: {
}) {
Text("Custom Button")
} : nil)
}
}
private func isButtonHidden(_ geometry: GeometryProxy) -> Bool {
// Alternatively, you can also check for geometry.frame(in:.global).origin.y if you know the button height.
if geometry.frame(in: .global).maxY <= 0 {
return true
}
return false
}
As mentioned by Oleg, depending on your use case, a possible issue with onAppear is its action will be performed as soon as the View is in a view hierarchy, regardless of whether the view is potentially visible to the user.
My use case is wanting to lazy load content when a view actually becomes visible. I didn't want to rely on the view being encapsulated in a LazyHStack or similar.
To achieve this I've added an extension onBecomingVisible to View that has the same kind of API as onAppear, but will only call the action when the view intersects the screen's visible bounds.
public extension View {
func onBecomingVisible(perform action: #escaping () -> Void) -> some View {
modifier(BecomingVisible(action: action))
}
}
private struct BecomingVisible: ViewModifier {
#State var action: (() -> Void)?
func body(content: Content) -> some View {
content.overlay {
GeometryReader { proxy in
Color.clear
.preference(
key: VisibleKey.self,
// See discussion!
value: UIScreen.main.bounds.intersects(proxy.frame(in: .global))
)
.onPreferenceChange(VisibleKey.self) { isVisible in
guard isVisible else { return }
action?()
action = nil
}
}
}
}
struct VisibleKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) { }
}
}
Discussion
I'm not thrilled by using UIScreen.main.bounds in the code! Perhaps a geometry proxy could be used for this instead, or some #Environment value – I've not thought about this yet though.

SwiftUI - dismissing keyboard on tapping anywhere in the view - issues with other interactive elements

I have a TextField and some actionable elements like Button, Picker inside a view. I want to dismiss the keyboard when the use taps outside the TextField. Using the answers in this question, I achieved it. However the problem comes with other actionable items.
When I tap a Button, the action takes place but the keyboard is not dismissed. Same with a Toggle switch.
When I tap on one section of a SegmentedStyle Picker, the keyboard is dimissed but the picker selection doesn't change.
Here is my code.
struct SampleView: View {
#State var selected = 0
#State var textFieldValue = ""
var body: some View {
VStack(spacing: 16) {
TextField("Enter your name", text: $textFieldValue)
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
Picker(selection: $selected, label: Text(""), content: {
Text("Word").tag(0)
Text("Phrase").tag(1)
Text("Sentence").tag(2)
}).pickerStyle(SegmentedPickerStyle())
Button(action: {
self.textFieldValue = "button tapped"
}, label: {
Text("Tap to change text")
})
}.padding()
.onTapGesture(perform: UIApplication.dismissKeyboard)
// .gesture(TapGesture().onEnded { _ in UIApplication.dismissKeyboard()})
}
}
public extension UIApplication {
static func dismissKeyboard() {
let keyWindow = shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
keyWindow?.endEditing(true)
}
}
As you can see in the code, I tried both options to get the tap gesture and nothing worked.
You can create an extension on View like so
extension View {
func endTextEditing() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
and use it for the Views you want to dismiss the keyboard.
.onTapGesture {
self.endTextEditing()
}
I have just seen this solution in a recent raywenderlich tutorial so I assume it's currently the best solution.
Dismiss the keyboard by tapping anywhere (like others suggested) could lead to very hard to find bug (or unwanted behavior).
you loose default build-in TextField behaviors, like partial text
selection, copy, share etc.
onCommit is not called
I suggest you to think about gesture masking based on the editing state of your fields
/// Attaches `gesture` to `self` such that it has lower precedence
/// than gestures defined by `self`.
public func gesture<T>(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture
this help us to write
.gesture(TapGesture().onEnded({
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}), including: (editingFlag) ? .all : .none)
Tap on the modified View will dismiss the keyboard, but only if editingFlag == true. Don't apply it on TextField! Otherwise we are on the beginning of the story again :-)
This modifier will help us to solve the trouble with Picker but not with the Button. That is easy to solve while dismiss the keyboard from its own action handler. We don't have any other controls, so we almost done
Finally we have to find the solution for rest of the View, so tap anywhere (excluding our TextFields) dismiss the keyboard. Using ZStack filled with some transparent View is probably the easiest solution.
Let see all this in action (copy - paste - run in your Xcode simulator)
import SwiftUI
struct ContentView: View {
#State var selected = 0
#State var textFieldValue0 = ""
#State var textFieldValue1 = ""
#State var editingFlag = false
#State var message = ""
var body: some View {
ZStack {
// TODO: make it Color.clear istead yellow
Color.yellow.opacity(0.1).onTapGesture {
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}
VStack {
TextField("Salutation", text: $textFieldValue0, onEditingChanged: { editing in
self.editingFlag = editing
}, onCommit: {
self.onCommit(txt: "salutation commit")
})
.padding()
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
TextField("Welcome message", text: $textFieldValue1, onEditingChanged: { editing in
self.editingFlag = editing
}, onCommit: {
self.onCommit(txt: "message commit")
})
.padding()
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
Picker(selection: $selected, label: Text(""), content: {
Text("Word").tag(0)
Text("Phrase").tag(1)
Text("Sentence").tag(2)
})
.pickerStyle(SegmentedPickerStyle())
.gesture(TapGesture().onEnded({
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}), including: (editingFlag) ? .all : .none)
Button(action: {
self.textFieldValue0 = "Hi"
print("button pressed")
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}, label: {
Text("Tap to change salutation")
.padding()
.background(Color.yellow)
.cornerRadius(10)
})
Text(textFieldValue0)
Text(textFieldValue1)
Text(message).font(.largeTitle).foregroundColor(Color.red)
}
}
}
func onCommit(txt: String) {
print(txt)
self.message = [self.textFieldValue0, self.textFieldValue1].joined(separator: ", ").appending("!")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If you miss onCommit (it is not called while tap outside TextField), just add it to your TextField onEditingChanged (it mimics typing Return on keyboard)
TextField("Salutation", text: $textFieldValue0, onEditingChanged: { editing in
self.editingFlag = editing
if !editing {
self.onCommit(txt: "salutation")
}
}, onCommit: {
self.onCommit(txt: "salutation commit")
})
.padding()
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
I'd like to take Mark T.s Answer even further and add the entire function to an extension for View:
extension View {
func hideKeyboardWhenTappedAround() -> some View {
return self.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
}
Can then be called like:
var body: some View {
MyView()
// ...
.hideKeyboardWhenTappedAround()
// ...
}
#user3441734 is smart to enable the dismiss gesture only when needed. Rather than forcing every crevice of your forms to track state, you can:
Monitor UIWindow.keyboardWillShowNotification / willHide
Pass the current keyboard state via an EnvironmentKey set at the/a root view
Tested for iOS 14.5.
Attach dismiss gesture to the form
Form { }
.dismissKeyboardOnTap()
Setup monitor in root view
// Root view
.environment(\.keyboardIsShown, keyboardIsShown)
.onDisappear { dismantleKeyboarMonitors() }
.onAppear { setupKeyboardMonitors() }
// Monitors
#State private var keyboardIsShown = false
#State private var keyboardHideMonitor: AnyCancellable? = nil
#State private var keyboardShownMonitor: AnyCancellable? = nil
func setupKeyboardMonitors() {
keyboardShownMonitor = NotificationCenter.default
.publisher(for: UIWindow.keyboardWillShowNotification)
.sink { _ in if !keyboardIsShown { keyboardIsShown = true } }
keyboardHideMonitor = NotificationCenter.default
.publisher(for: UIWindow.keyboardWillHideNotification)
.sink { _ in if keyboardIsShown { keyboardIsShown = false } }
}
func dismantleKeyboarMonitors() {
keyboardHideMonitor?.cancel()
keyboardShownMonitor?.cancel()
}
SwiftUI Gesture + Sugar
struct HideKeyboardGestureModifier: ViewModifier {
#Environment(\.keyboardIsShown) var keyboardIsShown
func body(content: Content) -> some View {
content
.gesture(TapGesture().onEnded {
UIApplication.shared.resignCurrentResponder()
}, including: keyboardIsShown ? .all : .none)
}
}
extension UIApplication {
func resignCurrentResponder() {
sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
extension View {
/// Assigns a tap gesture that dismisses the first responder only when the keyboard is visible to the KeyboardIsShown EnvironmentKey
func dismissKeyboardOnTap() -> some View {
modifier(HideKeyboardGestureModifier())
}
/// Shortcut to close in a function call
func resignCurrentResponder() {
UIApplication.shared.resignCurrentResponder()
}
}
EnvironmentKey
extension EnvironmentValues {
var keyboardIsShown: Bool {
get { return self[KeyboardIsShownEVK] }
set { self[KeyboardIsShownEVK] = newValue }
}
}
private struct KeyboardIsShownEVK: EnvironmentKey {
static let defaultValue: Bool = false
}
You can set .allowsHitTesting(false) to your Picker to ignore the tap on your VStack
Apply this to root view
.onTapGesture {
UIApplication.shared.endEditing()
}

Supporting different Shapes in ButtonStyle

I'm creating a Button with a custom ButtonStyle. I'd like this button style to support two different “appearances” - circular or rounded rectangle. To do this, I have defined an enum for the appearance cases and pass that through to the ButtonStyle so it can modify its properties as needed. I've run into a problem and that is trying to provide different Shapes to clipShape(). There's a compile-time error where I return some Shape:
Function declares an opaque return type, but the return statements in
its body do not have matching underlying types
What’s a good approach to resolve this error and implement the desired button appearances?
struct FloatingButton: View {
enum FloatingButtonStyleType {
case circle
case roundedRectangle
}
private let image: Image
private let style: FloatingButtonStyleType
private let action: () -> ()
var body: some View {
Button(action: action) {
image
}
.buttonStyle(FloatingButtonStyle(style: style))
}
init(image: Image, style: FloatingButtonStyleType, action: #escaping () -> ()) {
self.image = image
self.style = style
self.action = action
}
}
struct FloatingButtonStyle: ButtonStyle {
let style: FloatingButton.FloatingButtonStyleType
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(.all, 10)
.background(Color.black)
.clipShape(clipShape())
}
private func clipShape() -> some Shape { //FIXME: Function declares an opaque return type, but the return statements in its body do not have matching underlying types
switch style {
case .circle:
return Circle()
case .roundedRectangle:
return RoundedRectangle(cornerRadius: 5, style: .continuous)
}
}
}
Here is possible approach (compiled & worked, tested with Xcode 11.2 / iOS 13.2)
struct FloatingButtonStyle: ButtonStyle {
let style: FloatingButton.FloatingButtonStyleType
struct StyleModifier : ViewModifier {
let style: FloatingButton.FloatingButtonStyleType
func body(content: Self.Content) -> AnyView {
switch style {
case .circle:
return AnyView(content.clipShape(Circle()))
case .roundedRectangle:
return AnyView(content.clipShape(RoundedRectangle(cornerRadius: 5,
style: .continuous)))
}
}
}
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(.all, 10)
.background(Color.black)
.modifier(StyleModifier(style: style))
}
}

Resources