Supporting different Shapes in ButtonStyle - ios

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))
}
}

Related

SwiftUI: Menu with custom view? [duplicate]

In SwiftUI, there is a thing called a Menu, and in it you can have Buttons, Dividers, other Menus, etc. Here's an example of one I'm building below:
import SwiftUI
func testing() {
print("Hello")
}
struct ContentView: View {
var body: some View {
VStack {
Menu {
Button(action: testing) {
Label("Button 1", systemImage: "pencil.tip.crop.circle.badge.plus")
}
Button(action: testing) {
Label("Button 2", systemImage: "doc")
}
}
label: {
Label("", systemImage: "ellipsis.circle")
}
}
}
}
So, in the SwiftUI Playgrounds app, they have this menu:
My question is:
How did they make the circled menu option? I’ve found a few other cases of this horizontal set of buttons in a Menu, like this one below:
HStacks and other obvious attempts have all failed. I’ve looked at adding a MenuStyle, but the Apple’s docs on that are very lacking, only showing an example of adding a red border to the menu button. Not sure that’s the right path anyway.
I’ve only been able to get Dividers() and Buttons() to show up in the Menu:
I’ve also only been able to find code examples that show those two, despite seeing examples of other options in Apps out there.
It looks as this is only available in UIKit at present (and only iOS 16+), by setting
menu.preferredElementSize = .medium
To add this to your app you can add a UIMenu to UIButton and then use UIHostingController to add it to your SwiftUI app.
Here's an example implementation:
Subclass a UIButton
class MenuButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
let inspectAction = self.inspectAction()
let duplicateAction = self.duplicateAction()
let deleteAction = self.deleteAction()
setImage(UIImage(systemName: "ellipsis.circle"), for: .normal)
menu = UIMenu(title: "", children: [inspectAction, duplicateAction, deleteAction])
menu?.preferredElementSize = .medium
showsMenuAsPrimaryAction = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func inspectAction() -> UIAction {
UIAction(title: "Inspect",
image: UIImage(systemName: "arrow.up.square")) { action in
//
}
}
func duplicateAction() -> UIAction {
UIAction(title: "Duplicate",
image: UIImage(systemName: "plus.square.on.square")) { action in
//
}
}
func deleteAction() -> UIAction {
UIAction(title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive) { action in
//
}
}
}
Create a Menu using UIViewRepresentable
struct Menu: UIViewRepresentable {
func makeUIView(context: Context) -> MenuButton {
MenuButton(frame: .zero)
}
func updateUIView(_ uiView: MenuButton, context: Context) {
}
}
Works like a charm!
struct ContentView: View {
var body: some View {
Menu()
}
}

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)

Swift UI - I can't figure out why this button won't print the action I set

Ive been trying to figure out why my button won't perform the action I set it to. Any help would be appreciated!
The code works by sending in an action as input. I used another stack overflow linked in the code to get it but I cant quite figure out why it won't do the action. I tried setting another button in the view but it wouldn't do that action as well. Please help!
//
// LargeButton.swift
// Plan-It
//
// Created by Aarya Chandak on 5/13/22.
// https://stackoverflow.com/questions/58928774/button-border-with-corner-radius-in-swift-ui
import SwiftUI
struct LargeButtonStyle: ButtonStyle {
let backgroundColor: Color
let foregroundColor: Color
let isDisabled: Bool
func makeBody(configuration: Self.Configuration) -> some View {
let currentForegroundColor = isDisabled || configuration.isPressed ? foregroundColor.opacity(0.3) : foregroundColor
return configuration.label
.padding()
.foregroundColor(currentForegroundColor)
.background(isDisabled || configuration.isPressed ? backgroundColor.opacity(0.3) : backgroundColor)
// This is the key part, we are using both an overlay as well as cornerRadius
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(currentForegroundColor, lineWidth: 1)
)
.padding([.top, .bottom], 10)
.font(Font.system(size: 19, weight: .semibold))
}
}
struct LargeButton: View {
private static let buttonHorizontalMargins: CGFloat = 20
var backgroundColor: Color
var foregroundColor: Color
private let title: String
private let action: () -> Void
// It would be nice to make this into a binding.
private let disabled: Bool
init(title: String,
disabled: Bool = false,
backgroundColor: Color = Color.green,
foregroundColor: Color = Color.white,
action: #escaping () -> Void) {
self.backgroundColor = backgroundColor
self.foregroundColor = foregroundColor
self.title = title
self.action = action
self.disabled = disabled
}
var body: some View {
HStack {
Spacer(minLength: LargeButton.buttonHorizontalMargins)
Button(action:self.action) {
Text(self.title)
.frame(maxWidth:.infinity)
}
.buttonStyle(LargeButtonStyle(backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
isDisabled: disabled))
.disabled(self.disabled)
Spacer(minLength: LargeButton.buttonHorizontalMargins)
}
.frame(maxWidth:.infinity)
}
}
struct LargeButton_Previews: PreviewProvider {
static var previews: some View {
LargeButton(title: "Invite a Friend",
backgroundColor: Color.white,
foregroundColor: Color.green) {
print("Hello World")
}
}
}
You've set the button action only in the preview code.
But it's a print, and prints from previews don't get channeled to the Xcode's console.
Define the action in the actual UI of your app and run it, you should see the result of the print call in the console.

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

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.

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.

Resources