SwiftUI - Combine buttonStyle functionality with different functions for each button - swiftui

I am new to SwiftUI and iOS. What I am trying to achieve is to add a buttonStyle to all the buttons inside my app, while keeping the function that each button calls when pressed. This is my code so far for the buttonStyle. It works as expected (changes color, backgroundColor and opacity when tapped):
struct circularButton: ButtonStyle {
#State private var isPressed: Bool = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding()
.background(isPressed ? Color.white : Color.black)
.opacity(isPressed ? 0.6 : 1.0)
.foregroundColor(isPressed ? Color.black : Color.white)
.clipShape(Circle())
.pressEvents {
withAnimation(.easeIn(duration: 0.25)) {
isPressed = true
}
} onRelease: {
withAnimation {
isPressed = false
}
}
}
}
struct ButtonPress: ViewModifier {
var onPress: () -> Void
var onRelease: () -> Void
func body(content: Content) -> some View{
content
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged({_ in
onPress()
})
.onEnded({_ in
onRelease()
}))
}
}
extension View {
func pressEvents(onPress: #escaping (() -> Void), onRelease: #escaping (() -> Void)) -> some View {
modifier(ButtonPress(onPress: { onPress() }, onRelease: { onRelease() }))
}
}
However, the function attached to each button is not being called on click.
//Execute command is not being called when clicked
Button(action: {executeCommand(0x04)}) {
Image(systemName: "wifi.exclamationmark")
}
.buttonStyle(circularButton())
I understand it has something to do with the isPressed State, because when I comment out the .pressEvents lines, the function DOES get called. I am unsure of how to deal with this situation. Any help is appreciated.

For a button style, configuration already keeps track of when a button is pressed via the configuration.isPressed boolean. Using that instead of reading gestures would fix the gesture conflict you're experiencing right now.
struct circularButton: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding()
.background(configuration.isPressed ? Color.white : Color.black)
.opacity(configuration.isPressed ? 0.6 : 1.0)
.foregroundColor(configuration.isPressed ? Color.black : Color.white)
.clipShape(Circle())
.animation(.easeIn(duration: 0.25), value: configuration.isPressed)
}
}
If you want to learn more about custom button styles, this article on Hacking with Swift is a very good resource.

Related

SwiftUI keyboard toolbar. Is it possible to use it with UITexField

I have new SwiftUI .keyboard toolbar added. And it works great with Swiftui TextFields. But I consider if it is possible and how can it be done to use this toolbar also with UITextFields wrapped in UIViewRepresentable. I don’t know if I am doing something wrong or this isn’t supported.
I had the same problem and couldn't find an answer, so I tried to recreate this keyboard toolbar in SwiftUI. Here is the code:
struct SomeView: View {
#State var text = ""
#State var focusedUITextField = false
var body: some View {
NavigationStack {
ZStack {
VStack {
Button("Remove UITextField focus") {
focusedUITextField = false
}
TextField("SwiftUI", text: $text)
CustomTextField(hint: "UIKit", text: $text, focused: $focusedUITextField)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal)
if focusedUITextField {
VStack(spacing: 0) {
Spacer()
Divider()
keyboardToolbarContent
.frame(maxWidth: .infinity, maxHeight: 44)
.background(Color(UIColor.secondarySystemBackground))
}
}
}
/*
.toolbar {
ToolbarItem(placement: .keyboard) {
keyboardToolbarContent
}
}
*/
}
}
var keyboardToolbarContent: some View {
HStack {
Rectangle()
.foregroundColor(.red)
.frame(width: 50, height: 40)
Text("SwiftUI stuff")
}
}
}
And for the custom UITextField:
struct CustomTextField: UIViewRepresentable {
let hint: String
#Binding var text: String
#Binding var focused: Bool
func makeUIView(context: Context) -> UITextField {
let uiTextField = UITextField()
uiTextField.delegate = context.coordinator
uiTextField.placeholder = hint
return uiTextField
}
func updateUIView(_ uiTextField: UITextField, context: Context) {
uiTextField.text = text
uiTextField.placeholder = hint
if focused {
uiTextField.becomeFirstResponder()
} else {
uiTextField.resignFirstResponder()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
class Coordinator: NSObject, UITextFieldDelegate {
let parent: CustomTextField
init(parent: CustomTextField) {
self.parent = parent
}
func textFieldDidBeginEditing(_ textField: UITextField) {
parent.focused = true
}
func textFieldDidEndEditing(_ textField: UITextField) {
parent.focused = false
}
}
}
Unfortunately the animation of the custom toolbar and the keyboard don't match perfectly. I would challenge the guy in the comments from this post How to add a keyboard toolbar in SwiftUI that remains even when keyboard not visible but sadly I can't comment.
Also the background color doesn't match the native toolbar exactly, I don't know which color is used there.

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)

SwiftUI on tvOS: How to know which item is selected in a list

In a list, i need to know which item is selected and this item have to be clickable.
This is what i try to do:
| item1 | info of the item3 (selected) |
| item2 | |
|*item3*| |
| item4 | |
I can make it with .focusable() but it's not clickable.
Button or NavigationLink works but i can't get the current item selected.
When you use Button or NavigationLink .focusable don't hit anymore.
So my question is:
How i can get the current item selected (so i can display more infos about this item) and make it clickable to display the next view ?
Sample code 1: Focusable works but .onTap doesn't exists on tvOS
import SwiftUI
struct TestList: Identifiable {
var id: Int
var name: String
}
let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)") }
struct SwiftUIView : View {
var testList: [TestList]
var body: some View {
List {
ForEach(testList) { txt in
TestRow(row: txt)
}
}
}
}
struct TestRow: View {
var row: TestList
#State private var backgroundColor = Color.clear
var body: some View {
Text(row.name)
.focusable(true) { isFocused in
self.backgroundColor = isFocused ? Color.green : Color.blue
if isFocused {
print(self.row.name)
}
}
.background(self.backgroundColor)
}
}
Sample code 2: items are clickable via NavigationLink but there is no way to get the selected item and .focusable is not called anymore.
import SwiftUI
struct TestList: Identifiable {
var id: Int
var name: String
}
let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)") }
struct SwiftUIView : View {
var testList: [TestList]
var body: some View {
NavigationView {
List {
ForEach(testList) { txt in
NavigationLink(destination: Text("Destination")) {
TestRow(row: txt)
}
}
}
}
}
}
struct TestRow: View {
var row: TestList
#State private var backgroundColor = Color.clear
var body: some View {
Text(row.name)
.focusable(true) { isFocused in
self.backgroundColor = isFocused ? Color.green : Color.blue
if isFocused {
print(self.row.name)
}
}
.background(self.backgroundColor)
}
}
It seems like a major oversite to me you can't attach a click event in swiftui for tvos. I've come up with a hack that allows you to make most swiftui components selectable and clickable. Hope it helps.
First I need to make a UIView that captures the events.
class ClickableHackView: UIView {
weak var delegate: ClickableHackDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if event?.allPresses.map({ $0.type }).contains(.select) ?? false {
delegate?.clicked()
} else {
superview?.pressesEnded(presses, with: event)
}
}
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
delegate?.focus(focused: isFocused)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var canBecomeFocused: Bool {
return true
}
}
The clickable delegate:
protocol ClickableHackDelegate: class {
func focus(focused: Bool)
func clicked()
}
Then make a swiftui extension for my view
struct ClickableHack: UIViewRepresentable {
#Binding var focused: Bool
let onClick: () -> Void
func makeUIView(context: UIViewRepresentableContext<ClickableHack>) -> UIView {
let clickableView = ClickableHackView()
clickableView.delegate = context.coordinator
return clickableView
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<ClickableHack>) {
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, ClickableHackDelegate {
private let control: ClickableHack
init(_ control: ClickableHack) {
self.control = control
super.init()
}
func focus(focused: Bool) {
control.focused = focused
}
func clicked() {
control.onClick()
}
}
}
Then I make a friendlier swiftui wrapper so I can pass in any kind of component I want to be focusable and clickable
struct Clickable<Content>: View where Content : View {
let focused: Binding<Bool>
let content: () -> Content
let onClick: () -> Void
#inlinable public init(focused: Binding<Bool>, onClick: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.focused = focused
self.onClick = onClick
}
var body: some View {
ZStack {
ClickableHack(focused: focused, onClick: onClick)
content()
}
}
}
Example usage:
struct ClickableTest: View {
#State var focused1: Bool = false
#State var focused2: Bool = false
var body: some View {
HStack {
Clickable(focused: self.$focused1, onClick: {
print("clicked 1")
}) {
Text("Clickable 1")
.foregroundColor(self.focused1 ? Color.red : Color.black)
}
Clickable(focused: self.$focused2, onClick: {
print("clicked 2")
}) {
Text("Clickable 2")
.foregroundColor(self.focused2 ? Color.red : Color.black)
}
}
}
}
mark a view as focusable true (stating you want it to be able to have a focus), and implement onFocusChange to save the focus state
.focusable(true, onFocusChange: { focused in
isFocused = focused
})
you need to save the isFocused as a #State var
#State var isFocused: Bool = false
then style your View based on the isFocused value
.scaleEffect(isFocused ? 1.2 : 1.0)
here is a fully working example:
struct MyCustomFocus: View {
#State var isFocused: Bool = false
var body: some View {
Text("Select Me")
.focusable(true, onFocusChange: { focused in
isFocused = focused
})
.shadow(color: Color.black, radius: isFocused ? 10 : 5, x: 5, y: isFocused ? 20 : 5)
.scaleEffect(isFocused ? 1.2 : 1.0)
.animation(.spring().speed(2))
.padding()
}
}
struct CustomFocusTest: View {
var body: some View {
VStack
{
HStack
{
MyCustomFocus()
MyCustomFocus()
MyCustomFocus()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.yellow)
.ignoresSafeArea(.all) // frame then backround then ignore for full screen background (order matters)
.edgesIgnoringSafeArea(.all)
}
}
I haven't had much luck with custom button styles on tvOS, unfortunately.
However, to create a focusable, selectable custom view in SwiftUI on tvOS you can set the button style to plain. This allows you to keep the nice system-provided focus and selection animations, while you provide the destination and custom layout. Just add the .buttonStyle(PlainButtonStyle()) modifier to your NavigationLink:
struct VideoCard: View {
var body: some View {
NavigationLink(
destination: Text("Video player")
) {
VStack(alignment: .leading, spacing: .zero) {
Image(systemName: "film")
.frame(width: 356, height: 200)
.background(Color.white)
Text("Video Title")
.foregroundColor(.white)
.padding(10)
}
.background(Color.primary)
.frame(maxWidth: 400)
}
.buttonStyle(PlainButtonStyle())
}
}
Here's a screenshot of what it looks like in the simulator.
Clicking the button on the Siri remote, or Enter or a keyboard, should work as you'd expect.

Button is not selectable on tvOS using own ButtonStyle in SwiftUI

After replacing a standard button style with a custom one, the button isn't selectable anymore on tvOS (it works as expected on iOS). Is there a special modifier in PlainButtonStyle() that I'm missing? Or is it a bug in SwiftUI?
Here's the snipped that works:
Button(
action: { },
label: { Text("Start") }
).buttonStyle(PlainButtonStyle())
and here's the one that doesn't:
Button(
action: { },
label: { Text("Start") }
).buttonStyle(RoundedButtonStyle())
where RoundedButtonStyle() is defined as:
struct RoundedButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(6)
.foregroundColor(Color.white)
.background(Color.blue)
.cornerRadius(100)
}
}
If you create your own style you have to handle focus manual. Of course there are different ways how you could do this.
struct RoundedButtonStyle: ButtonStyle {
let focused: Bool
func makeBody(configuration: Configuration) -> some View {
configuration
.label
.padding(6)
.foregroundColor(Color.white)
.background(Color.blue)
.cornerRadius(100)
.shadow(color: .black, radius: self.focused ? 20 : 0, x: 0, y: 0) // 0
}
}
struct ContentView: View {
#State private var buttonFocus: Bool = false
var body: some View {
VStack {
Text("Hello World")
Button(
action: { },
label: { Text("Start") }
).buttonStyle(RoundedButtonStyle(focused: buttonFocus))
.focusable(true) { (value) in
self.buttonFocus = value
}
}
}
}
I have done it in this way and it's working fine, what you need to do is just handling the focused state.
struct AppButtonStyle: ButtonStyle {
let color: Color = .clear
func makeBody(configuration: Configuration) -> some View {
return AppButton(configuration: configuration, color: color)
}
struct AppButton: View {
#State var focused: Bool = false
let configuration: ButtonStyle.Configuration
let color: Color
var body: some View {
configuration.label
.foregroundColor(.white)
.background(RoundedRectangle(cornerRadius: 20).fill(color))
.compositingGroup()
.shadow(color: .black, radius: 5)
.scaleEffect(focused ? 1.1 : 1.0)
.padding()
.focusable(true) { focused in
withAnimation {
self.focused = focused
}
}
}
}
}
When the button is getting focused I'm just scaling it and you can do something else as you wish with the same idea, so let's say you wan't to change the background color:
.background(RoundedRectangle(cornerRadius: 20).fill(focused ? .red : .white))
To use:
struct SomeView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: Text("Destination")) {
Text("Hi")
}
.buttonStyle(AppButtonStyle())
.padding()
}
}
}

SwiftUI TextField touchable Area

SwiftUI layout is very different from what we are used to. Currently I'm fighting against TextFields. Specifically their touchable Area.
TextField(
.constant(""),
placeholder: Text("My text field")
)
.padding([.leading, .trailing])
.font(.body)
This results in a very small TextField (height wise)
Adding the frame modifier fixes the issue (visually)
TextField(
.constant(""),
placeholder: Text("My text field")
).frame(height: 60)
.padding([.leading, .trailing])
.font(.body)
but the touchable area remains the same.
I'm aware of the fact that the frame modifier does nothing else other than wrap the textField in another View with the specified height.
Is there any equivalent to resizable() for Image that will allow a taller TextField with wider touchable Area?
This solution only requires a #FocusState and an onTapGesture, and allows the user to tap anywhere, including the padded area, to focus the field. Tested with iOS 15.
struct MyView: View {
#Binding var text: String
#FocusState private var isFocused: Bool
var body: some View {
TextField("", text: $text)
.padding()
.background(Color.gray)
.focused($isFocused)
.onTapGesture {
isFocused = true
}
}
}
Bonus:
If you find yourself doing this on several text fields, making a custom TextFieldStyle will make things easier:
struct TappableTextFieldStyle: TextFieldStyle {
#FocusState private var textFieldFocused: Bool
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding()
.focused($textFieldFocused)
.onTapGesture {
textFieldFocused = true
}
}
}
Then apply it to your text fields with:
TextField("", text: $text)
.textFieldStyle(TappableTextFieldStyle())
Solution with Button
If you don't mind using Introspect you can do it by saving the UITextField and calling becomeFirstResponder() on button press.
extension View {
public func textFieldFocusableArea() -> some View {
TextFieldButton { self.contentShape(Rectangle()) }
}
}
fileprivate struct TextFieldButton<Label: View>: View {
init(label: #escaping () -> Label) {
self.label = label
}
var label: () -> Label
private var textField = Weak<UITextField>(nil)
var body: some View {
Button(action: {
self.textField.value?.becomeFirstResponder()
}, label: {
label().introspectTextField {
self.textField.value = $0
}
}).buttonStyle(PlainButtonStyle())
}
}
/// Holds a weak reference to a value
public class Weak<T: AnyObject> {
public weak var value: T?
public init(_ value: T?) {
self.value = value
}
}
Example usage:
TextField(...)
.padding(100)
.textFieldFocusableArea()
Since I use this myself as well, I will keep it updated on github: https://gist.github.com/Amzd/d7d0c7de8eae8a771cb0ae3b99eab73d
New solution using ResponderChain
The Button solution will add styling and animation which might not be wanted therefore I now use a new method using my ResponderChain package
import ResponderChain
extension View {
public func textFieldFocusableArea() -> some View {
self.modifier(TextFieldFocusableAreaModifier())
}
}
fileprivate struct TextFieldFocusableAreaModifier: ViewModifier {
#EnvironmentObject private var chain: ResponderChain
#State private var id = UUID()
func body(content: Content) -> some View {
content
.contentShape(Rectangle())
.responderTag(id)
.onTapGesture {
chain.firstResponder = id
}
}
}
You'll have to set the ResponderChain as environment object in the SceneDelegate, check the README of ResponderChain for more info.
Solution Without Any 3rd Parties
Increasing the tappable area can be done without third parties:
Step1: Create a modified TextField. This is done so we can define the padding of our new TextField:
Code used from - https://stackoverflow.com/a/27066764/2217750
class ModifiedTextField: UITextField {
let padding = UIEdgeInsets(top: 20, left: 5, bottom: 0, right: 5)
override open func textRect(forBounds bounds: CGRect) -> CGRect {
bounds.inset(by: padding)
}
override open func placeholderRect(forBounds bounds: CGRect) -> CGRect {
bounds.inset(by: padding)
}
override open func editingRect(forBounds bounds: CGRect) -> CGRect {
bounds.inset(by: padding)
}
}
Step 2: Make the new ModifiedTexField UIViewRepresentable so we can use it SwiftUI:
struct EnhancedTextField: UIViewRepresentable {
#Binding var text: String
init(text: Binding<String>) {
self._text = text
}
func makeUIView(context: Context) -> ModifiedTextField {
let textField = ModifiedTextField(frame: .zero)
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: ModifiedTextField, context: Context) {
uiView.text = text
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextFieldDelegate {
let parent: EnhancedTextField
init(_ parent: EnhancedTextField) {
self.parent = parent
}
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
}
}
Step3: Use the new EnhancedTextField wherever needed:
EnhancedTextField(placeholder: placeholder, text: $binding)
Note: To increase or decrease the tappable area just change the padding in ModifiedTextField
let padding = UIEdgeInsets(top: 20, left: 5, bottom: 0, right: 5)
A little work around but works.
struct CustomTextField: View {
#State var name = ""
#State var isFocused = false
let textFieldsize : CGFloat = 20
var textFieldTouchAbleHeight : CGFloat = 200
var body: some View {
ZStack {
HStack{
Text(name)
.font(.system(size: textFieldsize))
.lineLimit(1)
.foregroundColor(isFocused ? Color.clear : Color.black)
.disabled(true)
Spacer()
}
.frame(alignment: .leading)
TextField(name, text: $name , onEditingChanged: { editingChanged in
isFocused = editingChanged
})
.font(.system(size: isFocused ? textFieldsize : textFieldTouchAbleHeight ))
.foregroundColor(isFocused ? Color.black : Color.clear)
.frame( height: isFocused ? 50 : textFieldTouchAbleHeight , alignment: .leading)
}.frame(width: 300, height: textFieldTouchAbleHeight + 10,alignment: .leading)
.disableAutocorrection(true)
.background(Color.white)
.padding(.horizontal,10)
.padding(.vertical,10)
.border(Color.red, width: 2)
}
}
I don't know which is better for you.
so, I post two solution.
1) If you want to shrink only input area.
var body: some View {
Form {
HStack {
Spacer().frame(width: 30)
TextField("input text", text: $inputText)
Spacer().frame(width: 30)
}
}
}
2) shrink a whole form area
var body: some View {
HStack {
Spacer().frame(width: 30)
Form {
TextField("input text", text: $restrictInput.text)
}
Spacer().frame(width: 30)
}
}
iOS 15 Solution with TextFieldStyle and additional header (it can be removed if need)
extension TextField {
func customStyle(_ title: String) -> some View {
self.textFieldStyle(CustomTextFieldStyle(title))
}
}
extension SecureField {
func customStyle(_ title: String, error) -> some View {
self.textFieldStyle(CustomTextFieldStyle(title))
}
}
struct CustomTextFieldStyle : TextFieldStyle {
#FocusState var focused: Bool
let title: String
init(_ title: String) {
self.title = title
}
public func _body(configuration: TextField<Self._Label>) -> some View {
VStack(alignment: .leading) {
Text(title)
.padding(.horizontal, 12)
configuration
.focused($focused)
.frame(height: 48)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.foregroundColor(.gray)
)
}.onTapGesture {
focused = true
}
}
}
Try using an overlay with a spacer to create a larger tapable/touchable area.
Create a myText variable:
#State private var myText = ""
Then, create your TextField with the following example formatting with an overlay:
TextField("Enter myText...", text: $myText)
.padding()
.frame(maxWidth: .infinity)
.padding(.horizontal)
.shadow(color: Color(.gray), radius: 3, x: 3, y: 3)
.overlay(
HStack {
Spacer()
})
Hope this works for you!
quick workaround would be to just put TextField in a button, and it'll make keyboard open no matter where you tap (in button); I know it's not a solution but it gets the job done (sort of).