SwiftUI container view - how to provide default value for a property? - swiftui

I have a container view defined like this (according to https://www.swiftbysundell.com/tips/creating-custom-swiftui-container-views/):
import Foundation
import SwiftUI
let stackItemDefaultBackground: Color = Color(UIColor(white: 1, alpha: 0.05))
/// The goal is to make a container view which arranges passed content and adds passed background
struct SampleBgContainer<ContentView: View, BackgroundView: View>: View {
var alignment: HorizontalAlignment
var padding: CGFloat
var cornerRadius: CGFloat
var content: () -> ContentView
var background: () -> BackgroundView
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
#ViewBuilder content: #escaping () -> ContentView,
/// uncommenting the default value here leads to error
#ViewBuilder background: #escaping () -> BackgroundView // = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.content = content
self.background = background
}
var body: some View {
VStack(alignment: alignment) {
content()
}
.padding(EdgeInsets(all: self.padding))
.frame(idealWidth: .infinity, maxWidth: .infinity)
.background(background())
.clipShape(RoundedRectangle(cornerRadius: self.cornerRadius))
}
}
struct SampleBgContainer_Previews: PreviewProvider {
static var previews: some View {
Group {
ScrollView {
VStack {
SampleBgContainer(
content: {
Text("Hello")
Text("world")
},
background: { stackItemDefaultBackground }
)
SampleBgContainer(
content: { Text("Hello world") },
background: { stackItemDefaultBackground }
)
} /// vstack
} /// scrollview
} /// group
}
}
The problem is that I would like to provide a default value for background, but I don't know how.
Uncommenting the default value above leads to Cannot convert value of type 'Color' to closure result type 'BackgroundView' error -- while using a same value (background: { stackItemDefaultBackground }) in instantiation of view is OK and works.
Any ideas how to do this (how to convert color to View, which seems to happen implicitly when called in preview)?
Thank you!
Edit:
There seems to be two different problems:
A default value of an argument cannot be dynamically generated, but for SwiftUI we need this (as different views have to have different instances of background). We can solve this with Asperi's approach above, as it creates a fresh instance in every call.
Converting color to View. This got me half way, but the problem is that once I do this, the compiler cannot infer the type of BackgroundView:
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
title: String? = nil,
#ViewBuilder content: #escaping () -> ContentView
) {
self.init<ContentView, Rectangle>(
alignment: alignment,
padding: padding,
cornerRadius: cornerRadius,
title: title,
content: content,
/// default value
background: { Rectangle().background(VisualStyle.stackItemDefaultBackground) as! BackgroundView }
)
}
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
title: String? = nil,
#ViewBuilder content: #escaping () -> ContentView,
/// uncommenting the default value here leads to error
#ViewBuilder background: #escaping () -> BackgroundView // = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.title = title
self.content = content()
self.background = background()
}
Calling it with explicit types at least got it to compile, but it is ugly, as this leaks the internal Rectangle type into an interface:
SampleBgContainer<Text, Rectangle> {
Text("...")
}
However, even when doing this, the program still crashes at Rectangle().background(VisualStyle.stackItemDefaultBackground) as! BackgroundView line.
So, still no help.

You said “when I'd needed n optional arguments, I'd have to make n^2 initializers ...” It's worse than that, because it's 2^n (exponential), not n^2 (quadratic). But, n here is the number of generic arguments for which you want to provide defaults, which means n is usually quite small (1 for your SampleBgContainer).
You also said “I can't imagine this is how SwiftUI is implemented”, but yes, that's exactly how SwiftUI does it when SwiftUI wants to provide a default for a generic argument. For example, SwiftUI provides a Toggle initializer that lets you use a string instead of a View for the label. It converts the string into a Text for you, and it's declared like this:
extension Toggle where Label == Text {
public init(_ titleKey: LocalizedStringKey, isOn: Binding<Swift.Bool>)
}
Anyway, there is a solution to the exponential explosion of init overloads, and we can find it by looking at SwiftUI's Text.
You can tweak the appearance of a Text in lots of ways. For example, you can make it bold or italic, you can change the kerning or the tracking, and you can change the text color. But you don't pass any of these settings to Text's initializer. You do them all by calling modifiers on the Text. Some of modifiers (like foregroundColor) apply to any View, but others (like bold, italic, kerning, and tracking) are only available on Text directly.
You can use the same system: custom modifiers that work only on your type, instead of init arguments. This interface style lets you avoid the 2^n overload explosion.
First, omit all of the non-generic, defaultable arguments from init:
struct SampleContainer<Content: View, Background: View>: View {
var alignment: HorizontalAlignment = .leading
var padding: CGFloat = VisualStyle.stackItemPadding
var cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius
var content: Content
var background: Background
init(
#ViewBuilder content: () -> Content,
#ViewBuilder background: () -> Background
) {
self.content = content()
self.background = background()
}
var body: some View {
VStack(alignment: alignment) {
content
}
.padding(.all, self.padding)
.frame(idealWidth: .infinity, maxWidth: .infinity)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: self.cornerRadius))
}
}
Second, provide a single init overload that constrains all of the defaultable generic arguments to their default types. In your case, there's only one such argument:
extension SampleContainer where Background == Color {
init(
#ViewBuilder content: () -> Content
) {
self.content = content()
self.background = Color.white.opacity(0.05)
}
}
Finally, provide modifiers for changing the properties, including the generic arguments:
private func with(_ mutate: (inout Self) -> ()) -> Self {
var copy = self
mutate(&copy)
return copy
}
func stackAlignment(_ alignment: HorizontalAlignment) -> Self {
return self.with { $0.alignment = alignment }
}
func stackPadding(_ padding: CGFloat) -> Self {
return self.with { $0.padding = padding }
}
func stackRadius(_ radius: CGFloat) -> Self {
return self.with { $0.cornerRadius = radius }
}
func stackBackground<New: View>(#ViewBuilder _ background: () -> New) -> SampleContainer<Content, New> {
return .init(content: { content }, background: background)
}
}
Use it like this for default settings:
SampleContainer {
Text("default settings only")
Text("hello world")
}
Or use the modifiers to customize it:
SampleContainer {
Text("custom settings only")
Text("hello world")
}
.stackAlignment(.trailing)
.stackPadding(2)
.stackRadius(20)
.stackBackground {
LinearGradient(
colors: [
Color.red,
Color.blue,
],
startPoint: .top,
endPoint: .bottom
)
}

Here are possible variants of initializers
extension SampleBgContainer where BackgroundView == Color {
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
#ViewBuilder content: #escaping () -> ContentView,
#ViewBuilder background: #escaping () -> BackgroundView = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.content = content
self.background = background
}
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
#ViewBuilder content: #escaping () -> ContentView
) {
self.init(alignment: alignment, padding: padding, content: content, background: { stackItemDefaultBackground })
}
}
Tested with Xcode 13 / iOS 15

Related

How would you make Icons like in the Settings App (SwiftUI)

How could you achieve Icons like that?
I know that the base is this:
Image(systemName: "person.fill")
And than you could give it a background-Color:
Image(systemName: "person.fill")
.background(Color.blue)
To get rounded corners you could just add cornerRadius:
Image(systemName: "person.fill")
.background(Color.blue)
.cornerRadius(5)
But how would you make it that each of the items is in a square box with the same size?
Because SF Symbols don't have the same size.
And I don't want to make this:
Image(systemName: "person.fill")
.frame(width: 20, height: 20)
.background(Color.blue)
.cornerRadius(5)
The frame modifier would destroy the ability of SF Symbols to match with the preferred Font Size of the User.
Is there an other solution?
Or do you think the Settings App is done with .frame()?
Okay, I found an answer at Medium.
He works with Labels and adds an custom Modifier to them.
The Modifier looks like that:
struct ColorfulIconLabelStyle: LabelStyle {
var color: Color
var size: CGFloat
func makeBody(configuration: Configuration) -> some View {
Label {
configuration.title
} icon: {
configuration.icon
.imageScale(.small)
.foregroundColor(.white)
.background(RoundedRectangle(cornerRadius: 7 * size).frame(width: 28 * size, height: 28 * size).foregroundColor(color))
}
}
}
I did some changes:
struct ColorfulIconLabelStyle: LabelStyle {
var color: Color
func makeBody(configuration: Configuration) -> some View {
Label {
configuration.title
} icon: {
configuration.icon
.font(.system(size: 17))
.foregroundColor(.white)
.background(RoundedRectangle(cornerRadius: 7).frame(width: 28, height: 28).foregroundColor(color))
}
}
}
You can use it like that:
NavigationLink {
//Destination
} label: {
Label("Your Text", systemImage: "Your Image").labelStyle(ColorfulIconLabelStyle(color: .green))
}
This achieves a very native look :)
As I mentioned, full credits to Luca J.
I recommend this down way for you the update for my answer would be reading the device is zoomed or not! Then we could gave correct size for your UI, you can change your wished size in class.
struct ContentView: View {
var body: some View {
CustomButtonView(string: "gear", action: { print("setting!") })
CustomButtonView(string: "lasso.sparkles", action: { print("lasso!") })
CustomButtonView(string: "xmark.bin", action: { print("xmark!") })
CustomButtonView(string: "command", action: { print("command!") })
CustomButtonView(string: "infinity", action: { print("infinity!") })
}
}
struct CustomButtonView: View {
let string: String
let action: (() -> Void)?
init(string: String, action: (() -> Void)? = nil) {
self.string = string
self.action = action
}
#State private var tapped: Bool = Bool()
var body: some View {
Image(systemName: string)
.resizable()
.scaledToFit()
.frame(width: DeviceReader.shared.size - 5.0, height: DeviceReader.shared.size - 5.0)
.foregroundColor(Color.white)
.padding(5.0)
.background(tapped ? Color.blue : Color.gray)
.cornerRadius(10.0)
.onTapGesture { tapped.toggle(); action?() }
.animation(.interactiveSpring(), value: tapped)
}
}
class DeviceReader: ObservableObject {
let size: CGFloat
init() {
switch UIDevice.current.userInterfaceIdiom {
case .phone: self.size = 30.0
case .pad: self.size = 40.0
case .mac: self.size = 50.0
default: self.size = 30.0 }
}
static let shared: DeviceReader = DeviceReader()
}
I was looking at this question and your answer to it because this would be a useful thing to have. However, your answer does not allow the SFFont to scale with user preferences, and the answer you found on the Medium post does not scale well, as you can't just scale up and down with theses things. They look weird. If you run it in the simulator and change the Text setting, your will see what I mean.
I would simply use a .frame that changes it's size based off a preference key on the SF Symbol itself, and giving it a bit of padding extra. You could also simply add .padding() before your .background(), but the background would not necessarily be square. This method will set the width and height of the frame to slightly more than the biggest dimension of the SF Symbol, and it will fluidly change its size, not only allowing you to drop a .font() on it, but also handle the dynamic font sizes. This is a pure SwiftUI answer, using no UIKit.
struct ColoredIconView: View {
let imageName: String
let foregroundColor: Color
let backgroundColor: Color
#State private var frameSize: CGSize = CGSize(width: 30, height: 30)
#State private var cornerRadius: CGFloat = 5
var body: some View {
Image(systemName: imageName)
.overlay(
GeometryReader { proxy in
Color.clear
.preference(key: SFSymbolKey.self, value: max(proxy.size.width, proxy.size.height))
}
)
.onPreferenceChange(SFSymbolKey.self) {
let size = $0 * 1.05
frameSize = CGSize(width:size, height: size)
cornerRadius = $0 / 6.4
}
.frame(width: frameSize.width, height: frameSize.height)
.foregroundColor(foregroundColor)
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(backgroundColor)
)
}
}
fileprivate struct SFSymbolKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
Use it like this:
ColoredIconView(imageName: "airplane", foregroundColor: .white, backgroundColor: .orange)
.font(.body)

GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate]

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}

SwiftUI onTapGesture on Color.clear background behaves differently to Color.blue

I am making a custom Picker in the SegmentedPickerStyle(). I want to have the same behaviour but when I tap on the area between the content and the border of one of the possible selections the onTapGesture does not work. When I add a blue background it does work but with a clear background it doesn't.
Working with blue background
Not working with clear background
Not working code:
import SwiftUI
struct PickerElementView<Content>: View where Content : View {
#Binding var selectedElement: Int
let content: () -> Content
#inlinable init(_ selectedElement: Binding<Int>, #ViewBuilder content: #escaping () -> Content) {
self._selectedElement = selectedElement
self.content = content
}
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.background(Color.clear)
// ##################################################################
.border(Color.yellow, width: 5)
}
}
}
struct PickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
var elements: [(id: Int, view: AnyView)]
#Binding var selectedElement: Int
#State var internalSelectedElement: Int = 0
private var width: CGFloat = 220
private var height: CGFloat = 100
private var cornerRadius: CGFloat = 20
private var factor: CGFloat = 0.95
private var color = Color(UIColor.systemGray)
private var selectedColor = Color(UIColor.systemGray2)
init(_ selectedElement: Binding<Int>) {
self._selectedElement = selectedElement
self.elements = [
(id: 0, view: AnyView(PickerElementView(selectedElement) {
Text("9").font(.system(.title))
})),
(id: 1, view: AnyView(PickerElementView(selectedElement) {
Text("5").font(.system(.title))
})),
]
self.internalSelectedElement = selectedElement.wrappedValue
}
func calcXPosition() -> CGFloat {
var pos = CGFloat(-self.width * self.factor / 4)
pos += CGFloat(self.internalSelectedElement) * self.width * self.factor / 2
return pos
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(self.selectedColor)
.cornerRadius(self.cornerRadius * self.factor)
.frame(width: self.width * self.factor / CGFloat(self.elements.count), height: self.height - self.width * (1 - self.factor))
.offset(x: calcXPosition())
.animation(.easeInOut(duration: 0.2))
HStack {
ForEach(self.elements, id: \.id) { item in
item.view
.gesture(TapGesture().onEnded { _ in
print(item.id)
self.selectedElement = item.id
withAnimation {
self.internalSelectedElement = item.id
}
})
}
}
}
.frame(width: self.width, height: self.height)
.background(self.color)
.cornerRadius(self.cornerRadius)
.padding()
}
}
struct PickerView_Previews: PreviewProvider {
static var previews: some View {
PickerView(.constant(1))
}
}
Change the color where I marked it.
Does anyone know why they behave differently and how I can fix this?
The one line answer is instead of setting backgroundColor, please set contentShape for hit testing.
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.contentShape(Rectangle())
// ##################################################################
.border(Color.yellow, width: 5)
}
}
Transparent views are not tappable by default in SwiftUI because their content shape is zero.
You can change this behavior by using .contentShape modifier:
Color.clear
.frame(width: 300, height: 300)
.contentShape(Rectangle())
.onTapGesture { print("tapped") }
It appears to be a design decision that any Color with an opacity of 0 is untappable.
Color.clear.onTapGesture { print("tapped") } // will not print
Color.blue.opacity(0).onTapGesture { print("tapped") } // will not print
Color.blue.onTapGesture { print("tapped") } // will print
Color.blue.opacity(0.0001).onTapGesture { print("tapped") } // will print
You can use the 4th option to get around this, as it is visually indistinguishable from the 1st.
I was struggling a similar problem to get the tap on a RoundedRectangle.
My simple solution was to set the opacity to a very low value and it worked
RoundedRectangle(cornerRadius: 12)
.fill(Color.black)
.opacity(0.0001)
.frame(width: 32, height: 32)
.onTapGesture {
...
}

InputAccessoryView / View Pinned to Keyboard with SwiftUI

Is there an equivalent to InputAccessoryView in SwiftUI (or any indication one is coming?)
And if not, how would you emulate the behavior of an InputAccessoryView (i.e. a view pinned to the top of the keyboard)? Desired behavior is something like iMessage, where there is a view pinned to the bottom of the screen that animates up when the keyboard is opened and is positioned directly above the keyboard. For example:
Keyboard closed:
Keyboard open:
iOS 15.0+
macOS 12.0+,Mac Catalyst 15.0+
ToolbarItemPlacement has a new property in iOS 15.0+
keyboard
On iOS, keyboard items are above the software keyboard when present, or at the bottom of the screen when a hardware keyboard is attached.
On macOS, keyboard items will be placed inside the Touch Bar.
https://developer.apple.com
struct LoginForm: View {
#State private var username = ""
#State private var password = ""
var body: some View {
Form {
TextField("Username", text: $username)
SecureField("Password", text: $password)
}
.toolbar(content: {
ToolbarItemGroup(placement: .keyboard, content: {
Text("Left")
Spacer()
Text("Right")
})
})
}
}
iMessage like InputAccessoryView in iOS 15+.
struct KeyboardToolbar<ToolbarView: View>: ViewModifier {
private let height: CGFloat
private let toolbarView: ToolbarView
init(height: CGFloat, #ViewBuilder toolbar: () -> ToolbarView) {
self.height = height
self.toolbarView = toolbar()
}
func body(content: Content) -> some View {
ZStack(alignment: .bottom) {
GeometryReader { geometry in
VStack {
content
}
.frame(width: geometry.size.width, height: geometry.size.height - height)
}
toolbarView
.frame(height: self.height)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
extension View {
func keyboardToolbar<ToolbarView>(height: CGFloat, view: #escaping () -> ToolbarView) -> some View where ToolbarView: View {
modifier(KeyboardToolbar(height: height, toolbar: view))
}
}
And use .keyboardToolbar view modifier as you would normally do.
struct ContentView: View {
#State private var username = ""
var body: some View {
NavigationView{
Text("Keyboar toolbar")
.keyboardToolbar(height: 50) {
HStack {
TextField("Username", text: $username)
}
.border(.secondary, width: 1)
.padding()
}
}
}
}
I got something working which is quite near the wanted result. So at first, it's not possible to do this with SwiftUI only. You still have to use UIKit for creating the UITextField with the wanted "inputAccessoryView". The textfield in SwiftUI doesn't have the certain method.
First I created a new struct:
import UIKit
import SwiftUI
struct InputAccessory: UIViewRepresentable {
func makeUIView(context: Context) -> UITextField {
let customView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 44))
customView.backgroundColor = UIColor.red
let sampleTextField = UITextField(frame: CGRect(x: 20, y: 100, width: 300, height: 40))
sampleTextField.inputAccessoryView = customView
sampleTextField.placeholder = "placeholder"
return sampleTextField
}
func updateUIView(_ uiView: UITextField, context: Context) {
}
}
With that I could finally create a new textfield in the body of my view:
import SwiftUI
struct Test: View {
#State private var showInput: Bool = false
var body: some View {
HStack{
Spacer()
if showInput{
InputAccessory()
}else{
InputAccessory().hidden()
}
}
}
}
Now you can hide and show the textfield with the "showInput" state. The next problem is, that you have to open your keyboard at a certain event and show the textfield. That's again not possible with SwiftUI and you have to go back to UiKit and making it first responder. If you try my code, you should see a red background above the keyboard. Now you only have to move the field up and you got a working version.
Overall, at the current state it's not possible to work with the keyboard or with the certain textfield method.
I've solved this problem using 99% pure SwiftUI on iOS 14.
In the toolbar you can show any View you like.
That's my implementation:
import SwiftUI
struct ContentView: View {
#State private var showtextFieldToolbar = false
#State private var text = ""
var body: some View {
ZStack {
VStack {
TextField("Write here", text: $text) { isChanged in
if isChanged {
showtextFieldToolbar = true
}
} onCommit: {
showtextFieldToolbar = false
}
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
}
VStack {
Spacer()
if showtextFieldToolbar {
HStack {
Spacer()
Button("Close") {
showtextFieldToolbar = false
UIApplication.shared
.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
.foregroundColor(Color.black)
.padding(.trailing, 12)
}
.frame(idealWidth: .infinity, maxWidth: .infinity,
idealHeight: 44, maxHeight: 44,
alignment: .center)
.background(Color.gray)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I managed to create a nicely working solution with some help from this post by Swift Student, with quite a lot of modification & addition of functionality you take for granted in UIKit. It is a wrapper around UITextField, but that's completely hidden from the user and it's very SwiftUI in its implementation. You can take a look at it in my GitHub repo - and you can bring it into your project as a Swift Package.
(There's too much code to put it in this answer, hence the link to the repo)
I have a implementation that can custom your toolbar
public struct InputTextField<Content: View>: View {
private let placeholder: LocalizedStringKey
#Binding
private var text: String
private let onEditingChanged: (Bool) -> Void
private let onCommit: () -> Void
private let content: () -> Content
#State
private var isShowingToolbar: Bool = false
public init(placeholder: LocalizedStringKey = "",
text: Binding<String>,
onEditingChanged: #escaping (Bool) -> Void = { _ in },
onCommit: #escaping () -> Void = { },
#ViewBuilder content: #escaping () -> Content) {
self.placeholder = placeholder
self._text = text
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.content = content
}
public var body: some View {
ZStack {
TextField(placeholder, text: $text) { isChanged in
if isChanged {
isShowingToolbar = true
}
onEditingChanged(isChanged)
} onCommit: {
isShowingToolbar = false
onCommit()
}
.textFieldStyle(RoundedBorderTextFieldStyle())
VStack {
Spacer()
if isShowingToolbar {
content()
}
}
}
}
}
You can do it this way without using a UIViewRepresentable.
Its based on https://stackoverflow.com/a/67502495/5718200
.onReceive(NotificationCenter.default.publisher(for: UITextField.textDidBeginEditingNotification)) { notification in
if let textField = notification.object as? UITextField {
let yourAccessoryView = UIToolbar()
// set your frame, buttons here
textField.inputAccessoryView = yourAccessoryView
}
}
}

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