Extracting view to function or struct? - swift

I am new to learning Swift and SwiftUI. I have a struct with a View which contains a VStack. Within the VStack, I'm calling four functions which return a View.
var body: some View {
VStack {
self.renderLogo()
self.renderUserNameTextField()
self.renderPasswordSecureField()
self.renderLoginButton()
}
}
func renderLoginButton() -> some View {
return Button(action: {print("Button clicked")}){
Text("LOGIN").font(.headline).foregroundColor(.white).padding().frame(width: 220, height: 60).background(Color(red: 0, green: 70/255, blue: 128/255)).cornerRadius(15.0)
}.padding()
}[...]
I've just read that it's more common that views are extracted to a struct like that:
struct UsernameTextField : View {
#Binding var username: String
var body: some View {
return TextField("Username", text: $username)
.padding()
.background(lightGreyColor)
.cornerRadius(5.0)
.padding(.bottom, 20)
}
}
Why is that? What advantages are there using structs instead of a normal function?

This raises an interesting point. You don't want to make large views by having standard controls with lots of modifiers, but you want some kind of reuse, either by using a function to return the view or a custom view.
When I face these problems in SwiftUI I look to see what I am extracting. In your case it looks like the controls are standard, but they have different styling applied to them. The clue is in the function names that all have render.
In this case, rather than using a function or a custom View, I would write a custom modifier that applies a set of common styles to a control. A couple of examples from your code:
First, create ViewModifiers
struct InputTextFieldModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.gray)
.cornerRadius(5.0)
.padding(.bottom, 20)
}
}
struct ButtonTextModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(width: 220, height: 60)
.background(Color(red: 0, green: 70/255, blue: 128/255))
.cornerRadius(15.0)
}
}
These are quite simple to write. You just apply the modifiers you want to the content parameter.
And to make them easier to use, you can write extension to View
extension View {
func inputTextFieldStyle() -> some View {
modifier(InputTextFieldModifier())
}
func buttonTextStyle() -> some View {
modifier(ButtonTextModifier())
}
}
And this makes the call site look something like this:
var body: some View {
VStack {
...
TextField(...)
.inputTextFieldStyle()
...
Button(action: {print("Button clicked")}){
Text("LOGIN")
.buttonTextStyle()
}.padding()
}
}
If you want to be able to configure the modifier, that is easy as well. Say you want to specify the background colour of your common text fields, you can re-write the modifier to take this as a parameter:
struct InputTextFieldModifier: ViewModifier {
let backgroundColor: Color
func body(content: Content) -> some View {
content
.padding()
.background(backgroundColor)
.cornerRadius(5.0)
.padding(.bottom, 20)
}
}
And update your convenience function to take this as a parameter:
extension View {
func inputTextFieldStyle(backgroundColor: Color) -> some View {
modifier(InputTextFieldModifier(backgroundColor: backgroundColor))
}
}
And at the call site:
TextField("Username", text: $username)
.inputTextFieldStyle(backgroundColor: Color.gray)
And custom modifiers are reusable.

I would separate a view in a different struct if that view will have at least a #Binding with the main view. Otherwise, I would stick to functions. But...
It's up to you if you want to do it in separate structs or in a function but take into account that if you want to reuse a view, is easier if you have it declared as a separate entity.
Another purpose of this is to avoid having enormous views in SwiftUI, separating the responsibilities into smaller views makes the code easier to read.

It would be more advised to extract your views to structs, thus you can later reuse your views in your code, keeping your code more clean and isolated.

Related

Inserting non-list elements in a SwiftUI view

I am working on a SwiftUI page that consists of a table view with some rows but I would also like to have some non-cell elements in there. I am currently having some issues with this and I have tried various different avenues. I basically just need some elements in there that aren't wrapped inside a cell while still maintaining the tableview's grayish background. In my example below, I am trying to get an image right under the table rows.
Below is my code:
import SwiftUI
struct ContentView: View {
private var color = Color(red: 32/255, green: 35/255, blue: 0/255)
var body: some View {
NavigationView {
Form {
Section() {
HStack {
Text("AA")
.foregroundColor(color)
}
HStack {
Text("B")
.foregroundColor(color)
}
HStack {
Text("C")
.foregroundColor(color)
}
HStack {
Text("D")
.foregroundColor(color)
}
HStack {
Text("E")
.foregroundColor(color)
}
HStack {
Text("F")
.foregroundColor(color)
}
HStack {
Text("G")
.foregroundColor(color)
}
}
}.navigationBarTitle(Text("Page"))
HStack {
Image(systemName: "fallLeaves")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here are all of the scenarios I have tried that have been unsuccessful:
Scenario 1: Adding a HStack with the image outside of the List. (What the current code shows) This does not get the image to show as the table view takes the whole view.
Scenario 2: Adding the HStack with the image within the list block. This wraps the image around the cell like so
Scenario 3: Wrapping the list and the HStack contains the image inside a VStack. This is no good as it's basically like a split-screen with two different views. The table gets shrunk and has it's own scroll bar. Notice the scroll bar in the image below
The ideal solution would look like this but I'm not sure what to try as I can't get it to look like this where it's all one continuous view and the image isn't in a cell
i'll let you work out the padding and the rounding.
struct ContentView: View {
private var color = Color(red: 32/255, green: 35/255, blue: 0/255)
var body: some View {
NavigationView {
Form {
Section(content: {
HStack {
Text("AA")
.foregroundColor(color)
}
HStack {
Text("B")
.foregroundColor(color)
}
}, footer: {
Image("fallLeaves")
})
}.navigationBarTitle(Text("Page"))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can also use place your component (event a page view) inside the cell.
What can be done;
Use a Section to have a good style when you initiate another cell (because we'll make the following one transparent)
Create a new Section
Place a / any view
Add .listRowBackground(Color.clear) to the new view; to see the background transparently.
Add .listRowSeparator(.hidden)to the new view; to remove the cell line below.
After these, you'll have a placed view with additional padding. If this is important, you can play with padding to catch 0 padding distance. Also, by changing the list style (for this second group) with these kinds of styles, .listStyle(.grouped), you can get catch zero padding/spacing.
The footer solution can work for images, but when you need some texts, you'll see that it'll scale down the sizes. So instead of footer, my solution is to use a cell view and show it as a non-cell item;
Here is my change, which works (at least I think)
import SwiftUI
struct ContentView: View {
private var color = Color(red: 32/255, green: 35/255, blue: 255/255)
// Just changed color to test my changes better.
// private var color = Color(red: 32/255, green: 35/255, blue: 0/255)
var body: some View {
NavigationView {
Form {
Section() {
Text("AA")
Text("B")
Text("C")
Text("D")
Text("E")
Text("F")
Text("G")
}
//foreground color won't colorize the cell's bottom lines
.foregroundColor(color)
Section{
HStack{
// Use Spacer or another horizontal alignment
// I just tried with another image, you can place yours with full width and remove the Spacer() 's
Spacer()
Image(systemName: "checkmark.seal.fill")
.resizable()
.scaledToFit()
.foregroundColor(color)
.frame(width: 100)
Spacer()
}
.listRowBackground(Color.clear)
// Not required for single items; but would be usefull, if you'll add more views or sections
.listRowSeparator(.hidden)
}
}.navigationBarTitle(Text("Page"))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
And here is why your methods won't work;
Scenario 1: Using VStack and HStack will split the screen into different scrollable. This is by design, In SwiftUI List element overrides Stacks and ScrollableView
(Reference)
Scenario 2: Same as Scenario 1
Scenario 3: Actually, this is the same as Scenario 1
You might try 3 things;
My solution, I don't see a major problem
Footer solution, which can work, but for some views, you will have problems
Determine a background color in ZStack and develop a custom button object which looks like a cell view (cons: non-native way and long time consuming work around)

Appropriate extension for this use case

Originally I was looking to make an extension on Text for example:
extension Text {
var headerText: Text {
self
.bold()
.foregroundColor(.blue)
.padding() //<-- Doesn't work
}
}
and it all worked except for padding
So I had the bright idea of writing an extension on View instead but then the .bold() wouldn't work..
Looking for a more swifty way of doing this. Thanks
If I'm understanding correctly, this seems like the perfect case for a custom view modifier...
struct HeaderText: ViewModifier {
func body(content: Content) -> some View {
content
.bold()
.foregroundColor(.blue)
.padding()
}
}
...which you could then use like this:
struct ContentView: View {
var body: some View {
Text("This is a header")
.modifier(HeaderText())
}
}
You could also put the modifier inside a view extension to make it cleaner, like so:
extension View {
func headerText() -> ModifiedContent<Self, HeaderText> {
return modifier(HeaderText())
}
}
That would enable you to use it like this:
struct ContentView: View {
var body: some View {
Text("This is a header")
.headerText()
}
}

Custom Button in SwiftUI List

SwiftUI Custom Button in List
I'm trying to create a custom button in a SwiftUI List. I want it to have a blue background with white text, and importantly, to remain blue and go to 50% opacity when pressed, not the default grey.
I tried using a custom ButtonStyle, but when I do so, the tappable area of the button is reduced to just the label itself. If I tap any other part of the cell, the colour doesn't change. If I remove the ButtonStyle, tapping anywhere on the cell works
How can I fix this so that I get my custom colours, including the colour when tapped, but the whole cell is still tappable?
import SwiftUI
struct BlueButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(configuration.isPressed ? Color.white.opacity(0.5) : Color.white)
.listRowBackground(configuration.isPressed ? Color.blue.opacity(0.5) : Color.blue)
}
}
struct ExampleView: View {
var body: some View {
NavigationView {
List {
Section {
Text("Info")
}
Section {
Button(action: {print("pressed")})
{
HStack {
Spacer()
Text("Save")
Spacer()
}
}.buttonStyle(BlueButtonStyle())
}
}
.listStyle(GroupedListStyle())
.environment(\.horizontalSizeClass, .regular)
.navigationBarTitle(Text("Title"))
}
}
}
struct ExampleView_Previews: PreviewProvider {
static var previews: some View {
ExampleView()
}
}
In standard variant List intercepts and handles content area of tap detection, in your custom style it is defined, by default, by opaque area, which is only text in your case, so corrected style is
Update for: Xcode 13.3 / iOS 15.4
It looks like Apple broken something, because listRowBackground now works only inside List itself, no subview, which is senseless from generic concept of SwiftUI.
Updated solution with same behavior as on demo
Original for: Xcode 11.4 / iOS 13.4
struct BlueButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.contentShape(Rectangle())
.foregroundColor(configuration.isPressed ? Color.white.opacity(0.5) : Color.white)
.listRowBackground(configuration.isPressed ? Color.blue.opacity(0.5) : Color.blue)
}
}
and usage, just
Button(action: {print("pressed")})
{
Text("Save")
}.buttonStyle(BlueButtonStyle())
and even
Button("Save") { print("pressed") }
.buttonStyle(BlueButtonStyle())

Is there a way in SwiftUI to return a different view based on an optional binding having a value?

I am trying to show a view after fetching some data on my view model (where the data can be optional because the view model only fetches it on request).
Why is the following not possible / how should I go about it?
#Binding var someViewModel: SomeViewModel?
var body: some View {
if let viewModel = self.someViewModel {
return filledView(with: viewModel)
}
return emptyView()
}
The part not working here is an if let in the Swift UI view builder.
One solution would be to have a separate Bool that fires when data is loaded, or even an enum to identify when data is in and when it's not, but then the view code is full of optional value checking which isn't ideal.
E.g. I want to avoid doing something like:
Image(systemName: someViewModel?.icon.symbol() ?? "plus_sign")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.foregroundColor(Color.red)
.padding()
Any help would be greatly appreciated.
You could use map. It will unwrap the optional if it has some value or ignore it altogether:
var body: some View {
Group {
someViewModel.map { FilledView(with: $0) }
}
}
Just found another option, we could wrap both the empty and filled view function returns in an AnyView and the problems disappear as well.
Example of the empty would then become:
func emptyView() -> AnyView {
return AnyView(Text(""))
}
And example of the filled view becomes:
func filledView(for viewModel: SomeViewModel) -> AnyView {
return AnyView(VStack(alignment: .leading) {
Image(systemName: viewModel.icon.symbol())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.foregroundColor(Color.red)
.padding()
}
}
}

SwiftUI - Using GeometryReader Without Modifying The View Size

I have a header view which extends its background to be under the status bar using edgesIgnoringSafeArea. To align the content/subviews of the header view correctly, I need the safeAreaInsets from GeometryReader. However, when using GeometryReader, my view doesn't have a fitted size anymore.
Code without using GeometryReader
struct MyView : View {
var body: some View {
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.bottom, 64)
.background(Color.blue)
}
}
Preview
Code using GeometryReader
struct MyView : View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.top, geometry.safeAreaInsets.top)
.padding(.bottom, 64)
.background(Color.blue)
.fixedSize()
}
}
}
Preview
Is there a way to use GeometryReader without modifying the underlying view size?
Answer to the question in the title:
It is possible to wrap the GeometryReader in an .overlay() or .background(). Doing so will mitigate the layout changing effect of GeometryReader. The view will be laid out as normal, the GeometryReader will expand to the full size of the view and emit the geometry into its content builder closure.
It's also possible to set the frame of the GeometryReader to stop its eagerness in expanding.
For example, this example renders a blue rectangle, and a "Hello world" text inside at 3/4th the height of the rectangle (instead of the rectangle filling up all available space) by wrapping the GeometryReader in an overlay:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
})
Spacer()
}
}
Another example to achieve the same effect by setting the frame on the GeometryReader:
struct MyView : View {
var body: some View {
GeometryReader { geo in
Rectangle().fill(Color.blue)
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
}
.frame(height: 150)
Spacer()
}
}
However, there are caveats / not very obvious behaviors
1
View modifiers apply to anything up to the point that they are applied, and not to anything after. An overlay / background that is added after .edgesIgnoringSafeArea(.all) will respect the safe area (not participate in ignoring the safe area).
This code renders "Hello world" inside the safe area, while the blue rectangle ignores the safe area:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.edgesIgnoringSafeArea(.all)
.overlay(VStack {
Text("Hello world")
Spacer()
})
Spacer()
}
}
2
Applying .edgesIgnoringSafeArea(.all) to the background makes GeometryReader ignore the SafeArea:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
VStack {
Text("Hello world")
// No effect, safe area is set to be ignored.
.padding(.top, geo.safeAreaInsets.top)
Spacer()
}
})
.edgesIgnoringSafeArea(.all)
Spacer()
}
}
It is possible to compose many layouts by adding multiple overlays / backgrounds.
3
A measured geometry will be available to the content of the GeometryReader. Not to parent or sibling views; even if the values are extracted into a State or ObservableObject. SwiftUI will emit a runtime warning if that happens:
struct MyView : View {
#State private var safeAreaInsets = EdgeInsets()
var body: some View {
Text("Hello world")
.edgesIgnoringSafeArea(.all)
.background(GeometryReader(content: set(geometry:)))
.padding(.top, safeAreaInsets.top)
Spacer()
}
private func set(geometry: GeometryProxy) -> some View {
self.safeAreaInsets = geometry.safeAreaInsets
return Color.blue
}
}
I tried with the previewLayout and I see what you mean. However, I think the behavior is as expected. The definition of .sizeThatFits is:
Fit the container (A) to the size of the preview (B) when offered the
size of the device (C) on which the preview is running.
I intercalated some letters to define each part and make it more clear:
A = the final size of the preview.
B = The size of what you are modifying with .previewLayout(). In the first case, it's the VStack. But in the second case, it's the GeometryReader.
C = The size of the screen of the device.
Both views act differently, because VStack is not greedy, and only takes what it needs. GeometryReader, on the other side, tries to have it all, because it does not know what its child will want to use. If the child wants to use less, it can do it, but it has to start by being offered everything.
Perhaps if you edit your question to explain exactly what you would like to accomplish, I can refine my answer a little.
If you would like GeometryReader to report the size of the VStack. you may do so by putting it inside a .background modifier. But again, I am not sure what's the goal, so maybe that's a no go.
I have written an article about the different uses of GeometryReader. Here's the link, in case it helps: https://swiftui-lab.com/geometryreader-to-the-rescue/
UPDATE
Ok, with your additional explanation, here you have a working solution. Note that the Preview will not work, because safeInsets are reported as zero. On the simulator, however, it works fine:
As you will see, I use view preferences. They are not explained anywhere, but I am currently writing an article about them that I will post soon.
It may all look too verbose, but if you find yourself using it too often, you can encapsulate it inside a custom modifier.
import SwiftUI
struct InsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct InsetGetter: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().preference(key: InsetPreferenceKey.self, value: geometry.safeAreaInsets.top)
}
}
}
struct ContentView : View {
var body: some View {
MyView()
}
}
struct MyView : View {
#State private var topInset: CGFloat = 0
var body: some View {
VStack {
CustomView(inset: topInset)
.padding(.horizontal)
.padding(.bottom, 64)
.padding(.top, topInset)
.background(Color.blue)
.background(InsetGetter())
.edgesIgnoringSafeArea(.all)
.onPreferenceChange(InsetPreferenceKey.self) { self.topInset = $0 }
Spacer()
}
}
}
struct CustomView: View {
let inset: CGFloat
var body: some View {
VStack {
HStack {
Text("C \(inset)").color(.white).fontWeight(.bold).font(.title)
Spacer()
}
HStack {
Text("A").color(.white)
Text("B").color(.white)
Spacer()
}
}
}
}
I managed to solve this by wrapping the page main view inside a GeometryReader and pass down the safeAreaInsets to MyView. Since it is the main page view where we want the entire screen thus it is ok to be as greedy as possible.