How to change width of Divider in SwiftUI? - swiftui

On few screens of my SwiftUI app I am using Divider() between few elements. This Divider is rendered as a very thin grey (or black?) line. I am guessing 1 point. How can I change the width of Divider()?

You can create any divider you wish, colors, width, content... As in below example.
struct ExDivider: View {
let color: Color = .gray
let width: CGFloat = 2
var body: some View {
Rectangle()
.fill(color)
.frame(height: width)
.edgesIgnoringSafeArea(.horizontal)
}
}

For anyone interested, I modified accepted answer with support for both horizontal and vertical directions, as well as used original Divider colors with support for Dark Mode:
struct ExtendedDivider: View {
var width: CGFloat = 2
var direction: Axis.Set = .horizontal
#Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Rectangle()
.fill(colorScheme == .dark ? Color(red: 0.278, green: 0.278, blue: 0.290) : Color(red: 0.706, green: 0.706, blue: 0.714))
.applyIf(direction == .vertical) {
$0.frame(width: width)
.edgesIgnoringSafeArea(.vertical)
} else: {
$0.frame(height: width)
.edgesIgnoringSafeArea(.horizontal)
}
}
}
}
Bonus, this snippet uses applyIf syntax:
extension View {
#ViewBuilder func applyIf<T: View>(_ condition: #autoclosure () -> Bool, apply: (Self) -> T, else: (Self) -> T) -> some View {
if condition() {
apply(self)
} else {
`else`(self)
}
}
}

This worked for me:
Divider().frame(maxWidth: 100)
That was about a third of the width of the screen on portrait mode on an iPhone 11.

This is way late but...adding a background color to height will present a thicker Divider(). The divider is still razor thin within the image but the desired effect works.
Divider().frame(width: 300,height: 10).background(Color.blue)

I found this works best...
Group {
Divider()
.frame(height: 2.0)
.background(Color.blue)
}
.frame(width: 100)
once you place the Divider into the group, you can alter the surrounding area of the Divider by altering the Group
This would make the divider line 2 px high, and 100 px long

You can adjust the thickness of a Divider using frame(width, height) depending on if it is a vertical or horizontal Divider.
If you have a vertical Divider, then you can use frame(height: ?) to adjust the thickness. If you have a horizontal Divider, then you can use frame(width: ?). Along with setting the frame width or height you will also need to user the overlay modifier to give the Divider a color (even if you leave it grey).
example:
Divider()
.frame(height: 5)
.overlay(.gray)
*See this blog for more Divider customization
https://sarunw.com/posts/swiftui-divider/#:~:text=of%20a%20divider.-,Adjust%20SwiftUI%20Divider%20Thickness,-SwiftUI%20divider%20has

struct AppDivider: View {
var color: Color =.gray
var lineWidth: CGFloat = 1
var body: some View {
Divider()
.frame(height: lineWidth)
.overlay(color)
}
}
struct AppDivider_Previews: PreviewProvider {
static var previews: some View {
VStack {
AppDivider()
AppDivider(color: .red, lineWidth: 5)
}
}
}

Related

Swift UI ScrollView layout behaviour

Does ScrollView in SwiftUI proposes no height for its children.
ScrollView {
Color.clear
.frame(idealHeight: 200)
.background(.blue)
}
will result in
As written in frame documentation
The size proposed to this view is the size proposed to the frame, limited by any constraints specified, and with any ideal dimensions specified replacing any corresponding unspecified dimensions in the proposal.
So does it mean scrollView doesn't propose any height?
A ScrollView with a vertical axis does not offer a height because its height is potentially infinite.
So the child views adopt their idealHeight. Which in the case of a view that normally uses all the height offered, like Color, or Rectangle, corresponds to the magic number 10.
struct SwiftUIView: View {
#State private var height: CGFloat = 0
var body: some View {
ScrollView {
Rectangle()
.background(
GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
}
}
)
}
.overlay(Text(height.description), alignment: .bottom)
}
}
Note that we also find this magic number 10 (putting aside the ScrollView) if we ask the same Rectangle to use its idealHeight, using the modifier .fixedSize
struct SwiftUIView101: View {
#State private var height: CGFloat = 0
var body: some View {
VStack {
Rectangle()
.fixedSize()
.background(
GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
}
}
)
Text(height.description)
}
}
}

Dynamically set frame dimensions in a view extension

I've been playing around with giving views a gradient shadow (taken from here and here) and while these achieve most of what I need, they seem to have a flaw: the extension requires you to set a .frame height, otherwise the gradient looks really desaturated (as it's taking up the entire height of the device screen). It's a little hard to describe, so here's the code:
struct RainbowShadowCard: View {
#State private var cardGeometryHeight: CGFloat = 0.0
#State private var cardGeometryWidth: CGFloat = 0.0
var body: some View {
VStack {
Text("This is a card, it's pretty nice. It has a couple of lines of text inside it. Here are some more lines to see how it scales.")
.font(.system(.body, design: .rounded).weight(.medium))
}
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background {
GeometryReader { geo in
Color.black
.onAppear {
cardGeometryHeight = geo.size.height
cardGeometryWidth = geo.size.width
print("H: \(cardGeometryHeight), W: \(cardGeometryWidth)")
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
.multicolorGlow(cardHeight: cardGeometryHeight, cardWidth: cardGeometryWidth)
}
}
extension View {
func multicolorGlow(cardHeight: CGFloat, cardWidth: CGFloat) -> some View {
ZStack {
ForEach(0..<2) { i in
Rectangle()
.fill(
LinearGradient(colors: [
.red,
.green
], startPoint: .topLeading, endPoint: .bottomTrailing)
)
// The height of the frame dictates the saturation of
// the linear gradient. Without it, the gradient takes
// up the full width and height of the screen, resulting in
// a washed out / desaturated gradient around the card.
.frame(height: 300)
// My attempt at making the height and width of this view
// be based off the parent view
/*
.frame(width: cardWidth, height: cardHeight)
*/
.mask(self.blur(radius: 10))
.overlay(self.blur(radius: 0))
}
}
}
}
struct RainbowShadowCard_Previews: PreviewProvider {
static var previews: some View {
RainbowShadowCard()
}
}
I've managed to successfully store the VStack height and width in cardGeometryHeight and cardGeometryWidth states respectfully, but I can't figure out how to correctly pass that into the extension.
In the extension, if I uncomment:
.frame(width: cardWidth, height: cardHeight)
The VStack goes to a square of 32x32.
Edit
For the sake of clarity, the above solution "works" if you don't use a frame height value for the extension, but it doesn't work very nicely. Compare the saturation of the shadow in this image to the original, and you'll see a big difference between a non framed approach and a framed approach. The reason for this muddier gradient is the extension is using the screen bounds for the linear gradient, so our shadow gradient isn't getting the benefit of the "start" and "end" saturation of the red and green, but the middle blending of the two.

SwiftUI: Are .slide and .move transition animations broken?

It seems that Transition.slide and Transition.move animations are broken. Using AnyTransition.slide.animation() does not animate. It requires an additional .animation() modifier. This is a problem, since often I want to animate only the slide and nothing else.
Here is a demo. The goal is to have a sliding animation without animating the black ball. The .scale and .opacity animations work just fine. But .slide and .move either do not animate, or they animate the black ball.
import SwiftUI
struct ContentView: View {
#State var screens: [Color] = [Color.red]
var body: some View {
ZStack {
ForEach(screens.indices, id: \.self) { i -> AnyView in
let screen = screens[i]
return AnyView(
Screen(color: screen)
.zIndex(Double(i))
// Try uncommenting these lines one by one. The scale and opacity animations work fine. But move and slide animate only when the additional .animation modifier is uncommented below.
// This is a problem, since uncommenting the standalone .animation modifier, also animates everything inside the Screen() view, which is not what I want.
// .transition(AnyTransition.scale.animation(.linear(duration: 2)))
// .transition(AnyTransition.opacity.animation(.linear(duration: 2)))
// .transition(AnyTransition.move(edge: .leading).animation(.linear(duration: 2)))
.transition(AnyTransition.slide.animation(.linear(duration: 2)))
// .animation(.linear(duration: 2))
)
}
VStack(spacing: 50) {
Text("Screens Count: \(screens.count)")
Button("prev") {
removeScreen()
}
.padding()
.background(Color.white)
Button("next") {
addScreen()
}
.padding()
.background(Color.white)
}
.zIndex(1000)
}
}
func addScreen() {
let colors = [Color.red, Color.blue, Color.green, Color.yellow]
screens.append(colors[screens.count % colors.count])
}
func removeScreen() {
guard screens.count > 1 else { return }
screens.removeLast()
}
}
struct Screen: View {
static var width = UIScreen.main.bounds.width / 2 - 25
static var height = UIScreen.main.bounds.height / 2 - 25
var color: Color
#State var offset = CGSize(width: Self.width, height: -1 * Self.height)
var body: some View {
ZStack {
color
.frame(maxWidth: .infinity, maxHeight: .infinity)
Circle()
.frame(width: 50)
.offset(offset)
}
.onAppear { offset = CGSize(width: Self.width, height: Self.height) }
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Any thoughts how to use slide and move animations without animating everything else inside the view?
Ok, I have a solution. It's not perfect, but it's workable. Putting a view inside a NavigationView somehow separates .animation modifiers:
struct Screen: View {
static var width = UIScreen.main.bounds.width / 2 - 25
static var height = UIScreen.main.bounds.height / 2 - 25
var color: Color
#State var offset = CGSize(width: Self.width, height: -1 * Self.height)
var body: some View {
NavigationView {
ZStack {
color
.frame(maxWidth: .infinity, maxHeight: .infinity)
Circle()
.frame(width: 50)
.offset(offset)
}
.onAppear { offset = CGSize(width: Self.width, height: Self.height) }
.animation(.none)
.navigationBarHidden(true)
}
}
}

What is causing my SwiftUI view with higher layout priority to grab all the space?

I'm having problems laying out a VStack in SwiftUI that uses a custom pager controller, and despite trying loads of options I can't get it to behave the way I want.
Sample code:
import SwiftUI
struct ContentView: View {
#State var pageIndex: Int = 0
var body: some View {
VStack {
Text("Headline").font(.title)
Divider()
pagedView.background(Color.pink).layoutPriority(1)
Color.red
Color.blue
Color.gray
}
}
var pagedView: some View {
PagerManager(pageCount: 2, currentIndex: $pageIndex) {
VStack {
Text("Line 1")
Text("Line 2")
Text("Line 3")
Text("Line 4")
Text("Line 5")
Text("Line 6")
Text("Line 7")
Text("Line 8")
}
VStack {
Text("Line 21")
Text("Line 22")
Text("Line 23")
Text("Line 24")
Text("Line 25")
}
}
}
}
struct PagerManager<Content: View>: View {
let pageCount: Int
#Binding var currentIndex: Int
let content: Content
//Set the initial values for the variables
init(pageCount: Int, currentIndex: Binding<Int>, #ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self._currentIndex = currentIndex
self.content = content()
}
#GestureState private var translation: CGFloat = 0
var body: some View {
VStack {
singlePageView.background(Color.yellow)
HStack(spacing: 8) {
ForEach(0 ..< self.pageCount, id: \.self) { index in
CircleButton(isSelected: Binding<Bool>(get: { self.currentIndex == index }, set: { _ in })) {
withAnimation {
self.currentIndex = index
}
}
}
}
}
}
var singlePageView: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
self.content.frame(width: geometry.size.width).background(Color.purple)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * geometry.size.width)
.offset(x: self.translation)
.animation(.interactiveSpring())
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
struct CircleButton: View {
#Binding var isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: {
self.action()
}) { Circle()
.overlay(
Circle()
.stroke(Color.black,lineWidth: 1)
).foregroundColor(self.isSelected ? Color(UIColor.systemGray2) : Color.white.opacity(0.5))
.frame(width: 10, height: 10)
}
}
}
What I'm trying to achieve is for pagedView to get priority for it's own space and then the rest of the VStack views (red, blue, gray in the code) to have the remaining space divided equally between them.
Without layoutPriority(1) on pagedView the vertical space is divided equally between the 4 views (as expected), which does not leave enough space for the Text lines in the pagedView which are squashed into the bottom of the view. (Background colors added to show how the layout is working)
However, adding layoutPriority(1) on the pageView makes the embedded singlePageView (in yellow) grab all of the vertical space so the remaining Colors are not shown.
The issue seems to be somewhere in the singlePageView code, as changing singlePageView.background(Color.yellow) to content in PagerManager VStack means the layout behaves as I want (without the desired paging of course):
I know I could take a different approach and try wrapping a UIPageViewController in a UIViewControllerRepresentable but before I go down that route I'd like to know if there's a way of getting the pure SwiftUI approach to work
I'm not sure if your question is still an open issue for you, but for everybody else with a similar problem, the following explanation might help:
By using the layoutPriority modifier, you are asking SwiftUI to start laying out the body views of the ContentViews VStack with the singlePageView. This means that this view now gets the full available space offered. As you use the GeometryReader as your outermost view, it will claim the maximum space available no space leaving to all the remaining views. GeometryReader is like Color a view which will always occupy the maximum available space offered by the parent view.
(For more information about the SwiftUI layout process, read for example How layout works in SwiftUI from HackingWithSwiftUI.)
If you remove the layoutPriority modifier, all the views are equally important to the parent and therefore each of them will get a quarter of the available space. This means for your singlePageView that it is not enough to show it's full content: The GeometryReader could only claim a quarter of the available height.
Your main goal is, that the PagerManager is occupying the full horizontal screen space available but uses only the minimum required vertical space. You should therefore not use the GeometryReader as your main view in singlePageView as this will influence how its content will be laid out. But nevertheless you have to size the content of the page view to occupy the full width.
What can you do get your desired layout?
My solution:
Introduce a state variable pageWidth in PagerManager:
#State private var pageWidth: CGFloat = 600
Measure the screen width using GeometryReader on the background of the circle buttons parent HStack (the .frame(maxWidth: .infinity) modifier ensures that it occupies the whole available screen width):
HStack(spacing: 8) {
ForEach(0 ..< self.pageCount, id: \.self) { index in
// ... omitted CircleButton code
}
}
// Measure the width of the screen using GeometryReader on the background
.frame(maxWidth: .infinity)
.background(
GeometryReader { proxy -> Color in
DispatchQueue.main.async {
pageWidth = proxy.size.width
}
return Color.clear
}
)
Then in singlePageView remove the GeometryReader and replace all occurrences of geometry.size.width with pageWidth:
var singlePageView: some View {
HStack(spacing: 0) {
self.content.frame(width: pageWidth).background(Color.purple)
}
.frame(width: pageWidth, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * pageWidth)
.offset(x: self.translation)
.animation(.interactiveSpring())
.contentShape(Rectangle()) // ensures we can drag on the transparent background
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / pageWidth
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
As you can see I've added .contentShape(Rectangle()) modifier to ensure that the DragGesture also works on the empty background.

How to stop SwiftUI DragGesture from dying when View content changes

In my app, I drag a View horizontally to set a position of a model object. I can also drag it downward and release it to delete the model object. When it has been dragged downward far enough, I indicate the potential for deletion by changing its appearance. The problem is that this change interrupts the DragGesture. I don't understand why this happens.
The example code below demonstrates the problem. You can drag the light blue box side to side. If you pull down, it and it turns to the "rays" system image, but the drag dies.
The DragGesture is applied to the ZStack with a size of 50x50. The ZStack should continue to exist across that state change, no? Why is the drag gesture dying?
struct ContentView: View {
var body: some View {
ZStack {
DraggableThing()
}.frame(width: 300, height: 300)
.border(Color.black, width: 1)
}
}
struct DraggableThing: View {
#State private var willDeleteIfDropped = false
#State private var xPosition: CGFloat = 150
var body: some View {
//Rectangle()
// .fill(willDeleteIfDropped ? Color.red : Color.blue.opacity(0.3))
ZStack {
if willDeleteIfDropped {
Image(systemName: "rays")
} else {
Rectangle().fill(Color.blue.opacity(0.3))
}
}
.frame(width: 50, height: 50)
.position(x: xPosition, y: 150)
.gesture(DragGesture()
.onChanged { val in
print("drag changed \(val.translation)")
self.xPosition = 150 + val.translation.width
self.willDeleteIfDropped = (val.translation.height > 25)
}
.onEnded { val in
print("drag ended")
self.xPosition = 150
}
)
}
}
You need to keep content, which originally captured gesture. So your goal can be achieved with the following changes:
ZStack {
Rectangle().fill(Color.blue.opacity(willDeleteIfDropped ? 0.0 : 0.3))
if willDeleteIfDropped {
Image(systemName: "rays")
}
}