I'm trying to build a button style in SwiftUI and I'd like to use the preference technique to retrieve the configuration label's size, as explained in this article.
But I have a weird issue where my code in onPreferenceChange which is supposed to change my size property just does not change it. You'll see this in sample code but I set the new value, and the line after I print the #State property and I don't get the value I just set!
import SwiftUI
struct MajorButtonStyle: ButtonStyle {
#State private var labelSize: CGSize = .zero
func makeBody(configuration: Configuration) -> some View {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.accentColor)
.overlay(
configuration.label
.foregroundColor(.white)
.padding()
.modifier(SizeModifier())
.onPreferenceChange(SizePreferenceKey.self) { value in
print("PREFERENCE HAS CHANGED!!!!!")
print(value)
self.labelSize = value
print(value.height)
print(self.labelSize.height)
}
)
.frame(height: labelSize.height)
}
}
// MARK: Modifier and preference key
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct SizeModifier: ViewModifier {
private var sizeView: some View {
GeometryReader { geometry in
Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
}
}
func body(content: Content) -> some View {
content.background(sizeView)
}
}
Running the app and instantiating a button, here's the console output:
PREFERENCE HAS CHANGED!!!!!
(89.0, 52.33333333333333)
52.33333333333333
0.0 // 😫
Could someone tell me if I'm missing something? Or if I've fallen into some weird edge case, or bug? I'm testing this on Xcode 12 beta 2.
Thank you in advance.
What you try to do can be achieved in much simper way
struct MajorButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity) // << for all available width !!
.foregroundColor(.white)
.padding()
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.accentColor)
)
}
}
Note: btw, State wrapper works only in View, but ButtonStyle is not a view.
Related
TextEditor seems to have a default white background. So the following is not working and it displayed as white instead of defined red:
var body: some View {
TextEditor(text: .constant("Placeholder"))
.background(Color.red)
}
Is it possible to change the color to a custom one?
iOS 16
You should hide the default background to see your desired one:
TextEditor(text: .constant("Placeholder"))
.scrollContentBackground(.hidden) // <- Hide it
.background(.red) // To see this
iOS 15 and below
TextEditor is backed by UITextView. So you need to get rid of the UITextView's backgroundColor first and then you can set any View to the background.
struct ContentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
List {
TextEditor(text: .constant("Placeholder"))
.background(.red)
}
}
}
Demo
You can find my simple trick for growing TextEditor here in this answer
Pure SwiftUI solution on iOS and macOS
colorMultiply is your friend.
struct ContentView: View {
#State private var editingText: String = ""
var body: some View {
TextEditor(text: $editingText)
.frame(width: 400, height: 100, alignment: .center)
.cornerRadius(3.0)
.colorMultiply(.gray)
}
}
Update iOS 16 / SwiftUI 4.0
You need to use .scrollContentBackground(.hidden) instead of UITextView.appearance().backgroundColor = .clear
https://twitter.com/StuFFmc/status/1556561422431174656
Warning: This is an iOS 16 only so you'll probably need some if #available and potentially two different TextEditor component.
extension View {
/// Layers the given views behind this ``TextEditor``.
func textEditorBackground<V>(#ViewBuilder _ content: () -> V) -> some View where V : View {
self
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
.background(content())
}
}
Custom Background color with SwiftUI on macOS
On macOS, unfortunately, you have to fallback to AppKit and wrap NSTextView.
You need to declare a view that conforms to NSViewRepresentable
This should give you pretty much the same behaviour as SwiftUI's TextEditor-View and since the wrapped NSTextView does not draw its background, you can use the .background-ViewModifier to change the background
struct CustomizableTextEditor: View {
#Binding var text: String
var body: some View {
GeometryReader { geometry in
NSScrollableTextViewRepresentable(text: $text, size: geometry.size)
}
}
}
struct NSScrollableTextViewRepresentable: NSViewRepresentable {
typealias Representable = Self
// Hook this binding up with the parent View
#Binding var text: String
var size: CGSize
// Get the UndoManager
#Environment(\.undoManager) var undoManger
// create an NSTextView
func makeNSView(context: Context) -> NSScrollView {
// create NSTextView inside NSScrollView
let scrollView = NSTextView.scrollableTextView()
let nsTextView = scrollView.documentView as! NSTextView
// use SwiftUI Coordinator as the delegate
nsTextView.delegate = context.coordinator
// set drawsBackground to false (=> clear Background)
// use .background-modifier later with SwiftUI-View
nsTextView.drawsBackground = false
// allow undo/redo
nsTextView.allowsUndo = true
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
// get wrapped nsTextView
guard let nsTextView = scrollView.documentView as? NSTextView else {
return
}
// fill entire given size
nsTextView.minSize = size
// set NSTextView string from SwiftUI-Binding
nsTextView.string = text
}
// Create Coordinator for this View
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
// Declare nested Coordinator class which conforms to NSTextViewDelegate
class Coordinator: NSObject, NSTextViewDelegate {
var parent: Representable // store reference to parent
init(_ textEditor: Representable) {
self.parent = textEditor
}
// delegate method to retrieve changed text
func textDidChange(_ notification: Notification) {
// check that Notification.name is of expected notification
// cast Notification.object as NSTextView
guard notification.name == NSText.didChangeNotification,
let nsTextView = notification.object as? NSTextView else {
return
}
// set SwiftUI-Binding
parent.text = nsTextView.string
}
// Pass SwiftUI UndoManager to NSTextView
func undoManager(for view: NSTextView) -> UndoManager? {
parent.undoManger
}
// feel free to implement more delegate methods...
}
}
Usage
ContenView: View {
#State private var text: String
var body: some View {
VStack {
Text("Enter your text here:")
CustomizableTextEditor(text: $text)
.background(Color.red)
}
.frame(minWidth: 600, minHeight: 400)
}
}
Edit:
Pass reference to SwiftUI UndoManager so that default undo/redo actions are available.
Wrap NSTextView in NSScrollView so that it is scrollable. Set minSize property of NSTextView to enclosing SwiftUIView-Size so that it fills the entire allowed space.
Caveat: Only first line of this custom TextEditor is clickable to enable text editing.
This works for me on macOS
extension NSTextView {
open override var frame: CGRect {
didSet {
backgroundColor = .clear
drawsBackground = true
}
}
}
struct ContentView: View {
#State var text = ""
var body: some View {
TextEditor(text: $text)
.background(Color.red)
}
Reference this answer
To achieve this visual design here is the code I used.
iOS 16
TextField(
"free_form",
text: $comment,
prompt: Text("Type your feedback..."),
axis: .vertical
)
.lineSpacing(10.0)
.lineLimit(10...)
.padding(16)
.background(Color.themeSeashell)
.cornerRadius(16)
iOS 15
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 16)
.foregroundColor(.gray)
TextEditor(text: $comment)
.padding()
.focused($isFocused)
if !isFocused {
Text("Type your feedback...")
.padding()
}
}
.frame(height: 132)
.onAppear() {
UITextView.appearance().backgroundColor = .clear
}
You can use Mojtaba's answer (the approved answer). It works in most cases. However, if you run into this error:
"Return from initializer without initializing all stored properties"
when trying to use the init{ ... } method, try adding UITextView.appearance().backgroundColor = .clear to .onAppear{ ... } instead.
Example:
var body: some View {
VStack(alignment: .leading) {
...
}
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
Using the Introspect library, you can use .introspectTextView for changing the background color.
TextEditor(text: .constant("Placeholder"))
.cornerRadius(8)
.frame(height: 100)
.introspectTextView { textView in
textView.backgroundColor = UIColor(Color.red)
}
Result
import SwiftUI
struct AddCommentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
VStack {
if #available(iOS 16.0, *) {
TextEditor(text: $viewModel.commentText)
.scrollContentBackground(.hidden)
} else {
TextEditor(text: $viewModel.commentText)
}
}
.background(Color.blue)
.frame(height: UIScreen.main.bounds.width / 2)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.red, lineWidth: 1)
)
}
}
It appears the UITextView.appearance().backgroundColor = .clear trick in IOS 16,
only works for the first time you open the view and the effect disappear when the second time it loads.
So we need to provide both ways in the app. Answer from StuFF mc works.
var body: some View {
if #available(iOS 16.0, *) {
mainView.scrollContentBackground(.hidden)
} else {
mainView.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
}
// rename body to mainView
var mainView: some View {
TextEditor(text: $notes).background(Color.red)
}
So I've seen some approach to read the CGRect of a swiftui view using a technique where you setup a background containing a GeometryReader that is used to read the size of the view the background is set up. My issue is that this doesn't work if the view you're setting the background to has some ifs in it for example. See the code below for reference:
import SwiftUI
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct ChildRectReaderModifier: ViewModifier {
#Binding var rect: CGRect
func body(content: Content) -> some View {
content
.background(
GeometryReader { proxy in
Color.clear.preference(
key: RectPreferenceKey.self,
value: proxy.frame(in: .global)
)
}
)
.onPreferenceChange(RectPreferenceKey.self) { preferences in
if preferences != .zero && self.rect != preferences {
self.rect = preferences
}
}
}
}
extension View {
func childRectReader(_ rect: Binding<CGRect>) -> some View {
modifier(ChildRectReaderModifier(rect: rect))
}
}
struct ContentView: View {
#State var showOptionalText = false
#State var rect = CGRect.zero
var body: some View {
VStack {
Text("\(rect.width)")
Button {
showOptionalText.toggle()
} label: {
Text("Toggle")
}
VStack {
Text("Hello, world!")
Text("Hello, world!")
if showOptionalText {
Text("Hello, world!")
}
Text("Hello, world!")
}
.childRectReader($rect)
}
}
}
In the example above, the printed width is saying 0 unless I remove the if inside the inner VStack
Any idea why this happens? or any potential fixes?
As it is used in view modifier the preference is called several times on stack so reduce is active, but it is not correct here (just assign last one overriding all previous).
As I see for this scenario the fix is (tested with Xcode 13.4 / iOS 15.5)
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
// just remove anything from here !!
}
}
but if you wanted to handle multiple usage of this modifier for real views in same hierarchy then you have to use different model for this preference key (array, dictionary).
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.
I'm trying to find out what is practical difference between these two approaches. For example:
struct PrimaryLabel: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.black)
.foregroundColor(Color.white)
.font(.largeTitle)
.cornerRadius(10)
}
}
extension View {
func makePrimaryLabel() -> some View {
self
.padding()
.background(Color.black)
.foregroundColor(Color.white)
.font(.largeTitle)
.cornerRadius(10)
}
}
Then we can use all of them following way:
Text(tech.title)
.modifier(PrimaryLabel())
Text(tech.title)
.makePrimaryLabel()
ModifiedContent(
content: Text(tech.title),
modifier: PrimaryLabel()
)
All of the approaches you mentioned are correct. The difference is how you use it and where you access it. Which one is better? is an opinion base question and you should take a look at clean code strategies and SOLID principles and etc to find what is the best practice for each case.
Since SwiftUI is very modifier chain base, The second option is the closest to the original modifiers. Also you can take arguments like the originals:
extension Text {
enum Kind {
case primary
case secondary
}
func style(_ kind: Kind) -> some View {
switch kind {
case .primary:
return self
.padding()
.background(Color.black)
.foregroundColor(Color.white)
.font(.largeTitle)
.cornerRadius(10)
case .secondary:
return self
.padding()
.background(Color.blue)
.foregroundColor(Color.red)
.font(.largeTitle)
.cornerRadius(20)
}
}
}
struct ContentView: View {
#State var kind = Text.Kind.primary
var body: some View {
VStack {
Text("Primary")
.style(kind)
Button(action: {
self.kind = .secondary
}) {
Text("Change me to secondary")
}
}
}
}
We should wait and see what is the BEST practices in new technologies like this. Anything we find now is just a GOOD practice.
I usually prefer extensions, as they get you a more readable code and they are generally shorter to write. I wrote an article about View extensions.
However, there are differences. At least one. With ViewModifier you can have #State variables, but not with View extensions. Here's an example:
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, how are you?").modifier(ColorChangeOnTap())
}
}
}
struct ColorChangeOnTap: ViewModifier {
#State private var tapped: Bool = false
func body(content: Content) -> some View {
return content.foregroundColor(tapped ? .red : .blue).onTapGesture {
self.tapped.toggle()
}
}
}
I believe the best approach is combining ViewModifiers and View extension. This will allow composition of #State within the ViewModifier and convenience of View extension.
struct PrimaryLabel: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.black)
.foregroundColor(Color.white)
.font(.largeTitle)
.cornerRadius(10)
}
}
extension View {
func makePrimaryLabel() -> some View {
ModifiedContent(content: self, modifier: PrimaryLabel())
}
}
Usage
Text(tech.title)
.makePrimaryLabel()
It may be that there is an advantage in the type signature of the resulting Views when using a ViewModifier. For example if we create the following TestView to display the types of the three variants using this:
struct TestView: View {
init() {
print("body1: \(type(of: body))")
print("body2: \(type(of: body2))")
print("body3: \(type(of: body3))")
}
#ViewBuilder var body: some View {
Text("Some Label")
.modifier(PrimaryLabel())
}
#ViewBuilder var body2: some View {
Text("Some Label")
.makePrimaryLabel()
}
#ViewBuilder var body3: some View {
ModifiedContent(
content: Text("Some Label"),
modifier: PrimaryLabel()
)
}
}
We can see it yields the following types:
body1: ModifiedContent<Text, PrimaryLabel>
body2: ModifiedContent<ModifiedContent<ModifiedContent<ModifiedContent<ModifiedContent<Text, _PaddingLayout>, _BackgroundStyleModifier<Color>>, _EnvironmentKeyWritingModifier<Optional<Color>>>, _EnvironmentKeyWritingModifier<Optional<Font>>>, _ClipEffect<RoundedRectangle>>
body3: ModifiedContent<Text, PrimaryLabel>
Even if there isn't an advantage during execution it might make debugging a little easier if nothing else.
There is another approach: using View extension and a generic custom view. Using generic custom view resolves the issue that #kontiki mentioned (how to apply it to other views). Below is the code:
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, how are you?").colorChangeOnTap()
}
}
}
struct ColorChangeOnTap<Content: View>: View {
var content: Content
#State private var tapped: Bool = false
var body: some View {
return content.foregroundColor(tapped ? .red : .blue).onTapGesture {
self.tapped.toggle()
}
}
}
extension View {
func colorChangeOnTap() -> some View {
ColorChangeOnTap(content: self)
}
}
While being different, the approach is very similar to the view modifier approach. I suspect this might be what the SwiftUI team originally had and, when they added more features to it, it evolved into view modifier.
I am exploring SwiftUI as I am trying to build a login view and now I am facing a problem
This is what I am trying to achieve:
As you can see I already reached this point but I don't like my implementation
struct ContentView : View {
#State var username: String = ""
var body: some View {
VStack(alignment: .leading) {
Text("Login")
.font(.title)
.multilineTextAlignment(.center)
.lineLimit(nil)
Text("Please")
.font(.subheadline)
HStack {
VStack (alignment: .leading, spacing: 20) {
Text("Username: ")
Text("Password: ")
}
VStack {
TextField($username, placeholder: Text("type something here..."))
.textFieldStyle(.roundedBorder)
TextField($username, placeholder: Text("type something here..."))
.textFieldStyle(.roundedBorder)
}
}
}.padding()
}
}
Because in order to make the username and password text aligned exactly in the middle of the textfield, I had to put literal spacing value of 20 in the VStack which I don't like because most probably It won't work on different device sizes.
Anyone sees a better way to achieve the same result?
Thanks
We're going to implement two new View modifier methods so that we can write this:
struct ContentView: View {
#State var labelWidth: CGFloat? = nil
#State var username = ""
#State var password = ""
var body: some View {
VStack {
HStack {
Text("User:")
.equalSizedLabel(width: labelWidth, alignment: .trailing)
TextField("User", text: $username)
}
HStack {
Text("Password:")
.equalSizedLabel(width: labelWidth, alignment: .trailing)
SecureField("Password", text: $password)
}
}
.padding()
.textFieldStyle(.roundedBorder)
.storeMaxLabelWidth(in: $labelWidth)
}
}
The two new modifiers are equalSizedLabel(width:alignment:) and storeMaxLabelWidth(in:).
The equalSizedLabel(width:alignment) modifier does two things:
It applies the width and alignment to its content (the Text(“User:”) and Text(“Password:”) views).
It measures the width of its content and passes that up to any ancestor view that wants it.
The storeMaxLabelWidth(in:) modifier receives those widths measured by equalSizedLabel and stores the maximum width in the $labelWidth binding we pass to it.
So, how do we implement these modifiers? How do we pass a value from a descendant view up to an ancestor? In SwiftUI, we do this using the (currently undocumented) “preference” system.
To define a new preference, we define a type conforming to PreferenceKey. To conform to PreferenceKey, we have to define the default value for our preference, and we have to define how to combine the preferences of multiple subviews. We want our preference to be the maximum width of all the labels, so the default value is zero and we combine preferences by taking the maximum. Here's the PreferenceKey we'll use:
struct MaxLabelWidth: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = max(value, nextValue())
}
}
The preference modifier function sets a preference, so we can say .preference(key: MaxLabelWidth.self, value: width) to set our preference, but we have to know what width to set. We need to use a GeometryReader to get the width, and it's a little tricky to do properly, so we'll wrap it up in a ViewModifier like this:
extension MaxLabelWidth: ViewModifier {
func body(content: Content) -> some View {
return content
.background(GeometryReader { proxy in
Color.clear
.preference(key: Self.self, value: proxy.size.width)
})
}
}
What's happening above is we attach a background View to the content, because a background is always the same size as the content it's attached to. The background View is a GeometryReader, which (via the proxy) provides access to its own size. We have to give the GeometryReader its own content. Since we don't actually want to show a background behind the original content, we use Color.clear as the GeometryReader's content. Finally, we use the preference modifier to store the width as the MaxLabelWidth preference.
Now have can define the equalSizedLabel(width:alignment:) and storeMaxLabelWidth(in:) modifier methods:
extension View {
func equalSizedLabel(width: CGFloat?, alignment: Alignment) -> some View {
return self
.modifier(MaxLabelWidth())
.frame(width: width, alignment: alignment)
}
}
extension View {
func storeMaxLabelWidth(in binding: Binding<CGFloat?>) -> some View {
return self.onPreferenceChange(MaxLabelWidth.self) {
binding.value = $0
}
}
}
Here's the result:
You can use Spacers alongside with fixedSize modifier for height. You should set set heights of any row's object in order to achieve exact table style view:
struct ContentView : View {
private let height: Length = 32
#State var username: String = ""
var body: some View {
VStack(alignment: .leading) {
Text("Login")
.font(.title)
.multilineTextAlignment(.center)
.lineLimit(nil)
Text("Please")
.font(.subheadline)
HStack {
VStack (alignment: .leading) {
Text("Username: ") .frame(height: height)
Spacer()
Text("Password: ") .frame(height: height)
}
VStack {
TextField($username, placeholder: Text("type something here..."))
.textFieldStyle(.roundedBorder)
.frame(height: height)
Spacer()
TextField($username, placeholder: Text("type something here..."))
.textFieldStyle(.roundedBorder)
.frame(height: height)
}
}
.fixedSize(horizontal: false, vertical: true)
}
.padding()
}
}
Note that setting height on TextField does not effect it's height directly, but it will just set the height of it's content text's height.
If you are looking to do something similar with Buttons on macOS, be advised that as of Xcode 11.3 you'll end up with the following at run time:
instead of:
I've written up my solution in this blog post. It is quite similar to #rob-mayoff 's answer and works for both labels and buttons.