Inserting non-list elements in a SwiftUI view - swift

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)

Related

Extracting view to function or struct?

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.

Arrange custom views in SwiftUI without space or overlap

I'm trying to build a UI (SwiftUI / iOS) out of a number of custom views.
All those custom views have a defined aspect ratio or ratio for their frame.
Here's a simplified version of such a custom view:
struct TestView: View {
var body: some View {
GeometryReader { geometry in
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.blue)
.frame(height: geometry.size.width / 3)
}
}
}
My ContentView currently looks like that:
struct TestContentView: View {
var body: some View {
GeometryReader {geomerty in
VStack {
TestView()
TestView()
}
}
}
}
I would like to have the two rectangles to be positioned right below each other (at the top of the screen). So without any space between them. So a bit like an old-fashioned UITableView with only to rows.
But whatever I try, I only get one of two results:
They are equally spread out over the screen (vertically)
They overlap (= the view on the top only gets a vertical size of 20
The only solution I've found so far is to define the frame size of the sub-views also in the TestContentView(). But that seems to be quite un-SwiftUI.
Thanks!
Remove the GeometryReader from your content view, since it isn't doing anything
You said that your TestView has a defined aspect ratio, but, in fact, it doesn't -- it just has a defined width. If you do define an aspect ratio, it starts working as expected:
struct TestView: View {
var body: some View {
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.blue)
.aspectRatio(3, contentMode: .fit)
}
}
struct TestContentView: View {
var body: some View {
VStack(spacing: 0) {
TestView()
TestView()
Spacer()
}
}
}

SwiftUI TextField takes max width

I have a navigation list with each list item being in this format:
HStack {
TextField("Insert something here.",text: self.$userData.pages[i].title)
.border(Color.blue)
Spacer()
}
This results in the following view:
The touchable area is highlighted by the blue border and it takes the whole width of the row
The problem with this is that despite the list item being a navigation link, the user clicking anywhere along the item will result in them editing the text content. What I would prefer is a TextField that has the same width as a Text:
The blue border wraps the text instead of taking the max width
So if the user clicks outside the TextField, the navigation works, but if they click on the text, it will let them edit the text. (The above view is with Text field).
Apologies if I've asked an unclear or bad question. I'm new to Stack Overflow and SwiftUI.
Edit:
I've tried using the fixedSize modifier, and the TextField correctly wraps my Text, but now the Navigation Link doesn't work (i.e. clicking on it just doesn't navigate). This is my full code:
NavigationLink(destination: PageView(page: self.userData.pages[i])) {
HStack {
Button(action: {}){
TextField(" ", text: self.$userData.pages[i].title)
.fixedSize()
}
.buttonStyle(MyButtonStyle())
.border(Color.blue)
Spacer()
}
}
No need to apologize, your question is clear.
You can do this by using fixedSize()
so your code should be like this
HStack {
TextField("Insert something here.",text: self.$userData.pages[i].title)
.border(Color.blue)
.fixedSize()
Spacer()
}
You can further specify how would you like the stretch to be, either vertical or horizontal or even both by passing parameters like so
.fixedSize(horizontal: true, vertical: false)
UPDATED ANSWER TO MATCH YOUR NEW REQUIREMENTS
import SwiftUI
struct StackOverflow5: View {
#State var text: String = ""
#State var selection: Int? = nil
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: Page2(), tag: 1, selection:self.$selection) {
Color.clear
.onTapGesture {
self.selection = 1
}
}
TextField("Text", text: self.$text)
.fixedSize()
}
}
}
}
struct StackOverflow5_Previews: PreviewProvider {
static var previews: some View {
StackOverflow5()
}
}
struct Page2: View {
var body: some View {
Text("Page2")
}
}
We used a ZStack here to separate between our TextField and our NavigationLink so they can be interacted with separately.
Note the use of Color.clear before our TextField and this is on purpose so that our TextField has interaction priority. Also we used Color.clear because it will stretch as a background and it's clear so it's not visible.
Obviously I hard coded 1 here but this can be from List or a ForEach
Additionally, if you don't want to use selection and tag you can do something like this
...
#State var isActive: Bool = false
...
NavigationLink(destination: Page2(), isActive: self.$isActive) {
Color.clear
.onTapGesture {
self.isActive.toggle()
}
}
....

SwiftUI Navigation Bar Colour

I am trying to build a master/detail type sample application and I'm struggling to get the NavigationBar UI to work correctly in my detail view. The code for the view I am working on is as follows:
struct JobDetailView : View {
var jobDetails: JobDetails
var body: some View {
Form {
Section(header: Text("General")) {
HStack {
Text("Job Name")
Spacer()
Text(jobDetails.jobName)
.multilineTextAlignment(.trailing)
}
HStack {
Text("Hourly Rate")
Spacer()
Text(TextFormatters().currencyString(amount: jobDetails.hourlyRateBasic!))
.multilineTextAlignment(.trailing)
}
}
} .padding(.top)
.navigationBarTitle(Text(jobDetails.jobName))
}
}
The reason for the .padding(.top) is to stop the Form overlapping the navigation bar when scrolling upwards.
The white background on the navigation bar portion my issue (first image), I should expect it to be in keeping with the overall style of the UI, what I expect to happen is shown in the second image.
I have tried to add foreground/background colours and Views to change this colour, but to no avail. I'm also reticent of forcing a colour for the NagivationBar, as this will require further configuration for use with dark mode.
Current view when running application.
Expected view.
There's no available (SwiftUI) API for doing that (yet) (beta 5).
But we could use UINavigationBar.appearance(), as in:
UINavigationBar.appearance().backgroundColor = .clear
Full Code
import SwiftUI
struct JobDetailView: View {
init() {
UINavigationBar.appearance().backgroundColor = .clear
}
var body: some View {
NavigationView {
Form {
Section(header: Text("General")) {
HStack {
Text("Job Name")
Spacer()
Text("Scientist")
.multilineTextAlignment(.trailing)
}
HStack {
Text("Hourly Rate")
Spacer()
Text("$ 1.00")
.multilineTextAlignment(.trailing)
}
}
}
.navigationBarTitle("Scientist")
.navigationBarHidden(false)
}
}
}
#if DEBUG
struct JobDetailView_Previews: PreviewProvider {
static var previews: some View {
JobDetailView()
}
}
#endif
Result
Dark Mode Result
You don't need to change anything for your issue now (since it is the default behavior of SwiftUI). But about the title of your question:
From iOS 16
You can set any color to the background color of any toolbar background color (including the navigation bar) for the inline state with a simple native modifier:
.toolbarBackground(.yellow, in: .navigationBar)

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.