Aligning toggles in a SwiftUI View - swiftui

I have this code that contains the labels and toggles shown in the screenshot below
VStack(alignment: .leading) {
HStack() {
Text("Autostart:")
.font(.custom("SFProText-Medium", size: 12))
Toggle("Launch on Login", isOn: $launchAtLogin).onChange(of: launchAtLogin) { newValue in
LaunchAtLogin.isEnabled = newValue
if !isLoginItem() {
updateUserDefaults(key: "LaunchOnLogin", value: LaunchAtLogin.isEnabled)
}
}.font(.custom("SFProText-Medium", size: 12))
}
.padding(.top, 10)
.padding(.bottom, 10)
HStack() {
Text("Updates:")
.font(.custom("SFProText-Medium", size: 12))
Toggle("Allow Automatic Updates", isOn: $allowAutomaticUpdates).onChange(of: allowAutomaticUpdates) { newValue in
allowAutomaticUpdates = newValue
updateUserDefaults(key: "AllowsAutomaticUpdates", value: allowAutomaticUpdates)
}.font(.custom("SFProText-Medium", size: 12))
}
}
.padding(.top, 10)
.padding(.bottom, 10)
I'm trying to figure out how to get the two toggles to always be vertically aligned. I tried manually setting the frame size of them, as well wrapping them inside of another VStack, but none of that has seemed to work

One way to handle this is to define a custom horizontal alignment guide as follows:
extension HorizontalAlignment {
enum CustomAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[HorizontalAlignment.center]
}
}
static let custom = HorizontalAlignment(CustomAlignment.self)
}
Then use this as the alignment parameter for the outer VStack. To align trailing edges of the Text labels within the HStacks use the modifier
.alignmentGuide(.custom) { d in d[HorizontalAlignment.trailing] }
Putting this all together…
VStack(alignment: .custom) {
HStack() {
Text("Autostart:")
.alignmentGuide(.custom) { d in d[HorizontalAlignment.trailing] }
Toggle("Launch on Login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _ in
}
}
HStack() {
Text("Updates:")
.alignmentGuide(.custom) { d in d[HorizontalAlignment.trailing] }
Toggle("Allow Automatic Updates", isOn: $allowAutomaticUpdates)
.onChange(of: allowAutomaticUpdates) { _ in
}
}
}
.font(.custom("SFProText-Medium", size: 12))
.padding()
gives you…

Related

SwiftUI Animate Bar Between Custom Segment Control

I have designs for a custom Tab component (Segmented Control). The implementation is pretty basic, but one of the design requirements is for the bar at the bottom to animate between the different options (move on x axis + grow to new text size).
I have the below (WIP) implementation that statically swaps the items, but I am not sure how to get the animation between the items.
Using overlay allows for the bar to dynamically take up the full width of the parent, but I wonder if there needs to be a seperate bar that animates between the items.
Here is the WIP code:
struct Tabs: View {
#Binding var selectedTab: Int
var tabs: [Tab]
init(_ selectedTab: Binding<Int>, tabs: [Tab]) {
self._selectedTab = selectedTab
self.tabs = tabs
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(self.tabs.indices) { tabIndex in
let tab = self.tabs[tabIndex]
Button(action: {
withAnimation {
self.selectedTab = tabIndex
}
}) {
Text(tab.title)
.font(.body.weight(.medium))
.foregroundColor(.blue)
.padding(.bottom, 8)
.padding(.top, 2)
.padding(.horizontal, 4)
.if(tabIndex == self.selectedTab) {
$0.overlay(
Rectangle()
.fill(Color.blue)
.frame(width: .infinity, height: 3),
alignment: .bottom
)
}
}
}
}
}
}
}
and here is the expected design (note the underline bar, that is what I need to animate).
You create a new view under each tab during selection, this will not work. For SwiftUI, these will be different views, so they won't animate the position change.
Instead, I suggest you read this great article about alignment guides, especially the Cross Stack Alignment part.
So, using alignment guides, we can bind one of the view guides, such as center, to the selected center of the tab.
But we also need to get the width somehow. I do this with GeometryReader.
struct Tabs: View {
#State var selectedTab = 0
var tabs: [Tab]
#State private var tabWidths = [Int: CGFloat]()
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .crossAlignment, spacing: 0) {
HStack(spacing: 12) {
ForEach(self.tabs.indices) { tabIndex in
let tab = self.tabs[tabIndex]
Button(action: {
withAnimation {
self.selectedTab = tabIndex
}
}) {
Text(tab.title)
.font(.body.weight(.medium))
.foregroundColor(.blue)
.padding(.bottom, 8)
.padding(.top, 2)
.padding(.horizontal, 4)
.if(tabIndex == self.selectedTab) {
$0.alignmentGuide(.crossAlignment) { d in
d[HorizontalAlignment.center]
}
}
}.sizeReader { size in
tabWidths[tabIndex] = size.width
}
}
}
Rectangle()
.fill(Color.blue)
.frame(width: tabWidths[selectedTab], height: 3)
.alignmentGuide(.crossAlignment) { d in
d[HorizontalAlignment.center]
}
}
}
}
}
extension View {
func sizeReader(_ block: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometry -> Color in
DispatchQueue.main.async { // to avoid warning
block(geometry.size)
}
return Color.clear
}
)
}
}
extension HorizontalAlignment {
private enum CrossAlignment: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[HorizontalAlignment.center]
}
}
static let crossAlignment = HorizontalAlignment(CrossAlignment.self)
}
p.s. Don't use .frame(width: .infinity) to extend the view, use .frame(maxWidth: .infinity) instead. Yes, you must split it into two modifiers if you want to provide a static height.
p.s.s. You should use if modifier very carefully. It's fine in this case, but in most cases it will break your animation, see this article to understand why.

How to layout properly in ZStack (I have visibility problem)?

Here is reproducable small code below;
As you'll see when you run the demo code, the Element view does stay under Color.blue when dragged eventhough its above according to ZStack. By the way I also played with zIndex modifier but still no luck. Any solution you offer? Thanks all.
struct ContentView: View {
var body: some View {
GeometryReader { gr in
ZStack {
Color.blue.opacity(0.3)
.aspectRatio(1, contentMode: .fit)
.frame(width: gr.size.width)
VStack {
Spacer()
ScrollView(.horizontal) {
HStack {
ForEach(1...15, id: \.self) { (idx) in
Element(index: idx)
}
}
.padding()
}
.background(Color.secondary.opacity(0.3))
}
}
}
}
}
struct Element: View {
#State private var dragAmount = CGSize.zero
var index: Int
var body: some View {
Rectangle()
.frame(width: 80, height: 80)
.overlay(Text("\(index)").bold().foregroundColor(.white))
.offset(dragAmount)
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged {
self.dragAmount = CGSize(width: $0.translation.width, height: $0.translation.height)
}
.onEnded { _ in
self.dragAmount = .zero
}
)
}
}
iOS 15.5: still valid
How can achieve my goal then, like dragging Element on different view (in this scenario Color.blue)
Actually we need to disable clipping by ScrollView.
Below is possible approach based on helper extensions from my other answers (https://stackoverflow.com/a/63322713/12299030 and https://stackoverflow.com/a/60855853/12299030)
VStack {
Spacer()
ScrollView(.horizontal) {
HStack {
ForEach(1...15, id: \.self) { (idx) in
Element(index: idx)
}
}
.padding()
.background(ScrollViewConfigurator {
$0?.clipsToBounds = false // << here !!
})
}
.background(Color.secondary.opacity(0.3))
}

Edited: How we can alignment Image with Images and Text withTexts in SwiftUI, Multi-alignment?

I have a VStack which has some HStack as you can see in my codes, inside my each Hstack there is an Image and Text, after running my codes the Alignmet of all codes is ugly, I want the Image alignment center together and Text alignment leading. How I can solve the problem?
I can make all Image .center Alignment, and also all Text .leading Alignment. But I can not make both happen at same time.
struct CustomAlignment: AlignmentID
{
static func defaultValue(in context: ViewDimensions) -> CGFloat
{
return context[HorizontalAlignment.center]
}
}
struct CustomAlignment2: AlignmentID
{
static func defaultValue(in context: ViewDimensions) -> CGFloat
{
return context[HorizontalAlignment.leading]
}
}
extension HorizontalAlignment
{
static let custom: HorizontalAlignment = HorizontalAlignment(CustomAlignment.self)
static let custom2: HorizontalAlignment = HorizontalAlignment(CustomAlignment2.self)
}
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(alignment: .custom)
{
HStack()
{
Image(systemName: "folder")
.alignmentGuide(.custom) { $0[HorizontalAlignment.center] }
Text("Some texts here.")
.alignmentGuide(.custom2) { $0[HorizontalAlignment.leading] }
Spacer()
}
HStack()
{
Image(systemName: "music.note")
.alignmentGuide(.custom) { $0[HorizontalAlignment.center] }
Text("Some texts here.")
.alignmentGuide(.custom2) { $0[HorizontalAlignment.leading] }
Spacer()
}
HStack()
{
Image(systemName: "person.fill.questionmark")
.alignmentGuide(.custom) { $0[HorizontalAlignment.center] }
Text("Some texts here.")
.alignmentGuide(.custom2) { $0[HorizontalAlignment.leading] }
Spacer()
}
}
.padding()
Spacer()
}
}
Use custom alignment guide if you want precise control.
About your comment on using fixed frame, here is an article which explains how frame works in SwiftUI.
Basically, frame modifier just adds a fixed size frame around the SF in this case, but it won't alter the intrinsic size.
struct ContentView: View {
var body: some View {
VStack(alignment: .sfView) {
SFView(title: "This is some text", image: "folder")
SFView(title: "SwiftUI is cool. Combine is cooler.", image: "snow")
SFView(title: "This is a music note. This has a different length.", image: "music.note")
}
}
}
private struct SFView: View {
let title: String
let image: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: image)
.font(.system(size: 20))
.frame(width: 32, height: 32)
.alignmentGuide(.sfView) { d in d[HorizontalAlignment.center] }
Text(title)
.alignmentGuide(.sfView) { d in d[HorizontalAlignment.leading] }
}
}
}
private extension HorizontalAlignment {
struct SFViewAlignment: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[HorizontalAlignment.leading]
}
}
static let sfView = HorizontalAlignment(SFViewAlignment.self)
}
You have to give a frame to the image as some SF Symbols are larger than others, also try to create reusable views.
try something like this:
struct ContentView: View {
var body: some View {
VStack {
RowView(title: "Some texts here.", image: "folder")
RowView(title: "Some texts here.", image: "person.fill.questionmark")
RowView(title: "Some texts here.", image: "snow")
RowView(title: "Some texts here.", image: "forward.end.alt.fill")
}
.padding()
Spacer()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct RowView: View {
let title: String
let image: String
var body: some View {
// Option 1 with Label
// Label(
// title: {
// Text(title)
// },
// icon: {
// Image(systemName: image)
// .frame(width: 30, height: 30)
// }
// )
// Option 2 with HStack
HStack {
Image(systemName: image)
.frame(width: 30, height: 30)
Text(title)
Spacer()
}
}
}
You are WAY overcomplicating it. You don't need to use all these custom alignments and constraints for something this simple.
Go back to basics and use a regular VStack / HStack and just set the icon to be an exact frame. The issue was arising because the icons had slightly different widths.
struct TestView: View {
var body: some View {
VStack(alignment: .leading) {
HStack {
Image(systemName: "folder")
.frame(width: 30, height: 30, alignment: .center)
Text("Some text here")
}
HStack {
Image(systemName: "person.fill.questionmark")
.frame(width: 30, height: 30, alignment: .center)
Text("Some text here")
}
HStack {
Image(systemName: "snow")
.frame(width: 30, height: 30, alignment: .center)
Text("Some text here")
}
HStack {
Image(systemName: "music.note")
.frame(width: 30, height: 30, alignment: .center)
Text("Some text here")
}
}
}
}

How to make SwiftUI to forget internal state

I have a view like following
struct A: View {
var content: AnyView
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack {
// Common Elements
content
// More Common Elements
}
}
}
}
When I call this from another view like
A(nextInnerView())
two things happen. Firstly, as the size of the content element changes ScrollView animates the transition. Secondly, if you scroll down and then change the content the scrolling position does not reset.
Here is a demo of possible solution. Tested with Xcode 11.4 / iOS 13.4
The origin of this behaviour is in SwiftUI rendering optimisation, that tries to re-render only changed part, so approach is to identify view A (to mark it as completely changed) based on condition that originated in interview changes, alternatively it can be identified just by UUID().
struct TestInnerViewReplacement: View {
#State private var counter = 0
var body: some View {
VStack {
Button("Next") { self.counter += 1 }
Divider()
A(content: nextInnerView())
.id(counter) // << here !!
}
}
private func nextInnerView() -> AnyView {
AnyView(Group {
if counter % 2 == 0 {
Text("Text Demo")
} else {
Image(systemName: "star")
.resizable()
.frame(width: 100, height: 100)
}
})
}
}
struct A: View {
var content: AnyView
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack {
ForEach(0..<5) { _ in // upper content demo
Rectangle().fill(Color.yellow)
.frame(height: 40)
.frame(maxWidth: .infinity)
.padding()
}
content
ForEach(0..<10) { _ in // lower content demo
Rectangle().fill(Color.blue)
.frame(height: 40)
.frame(maxWidth: .infinity)
.padding()
}
}
}
}
}

SwiftUI reduce spacing of rows in a list to null

I want to reduce the linespacing in a list to null.
My tries with reducing the padding did not work.
Setting ´.environment(.defaultMinListRowHeight, 0)´ helped a lot.
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.padding(0)
//.frame(height: 60)
.background(Color.yellow)
}
//.frame(height: 60)
.padding(0)
.background(Color.blue)
}
.environment(\.defaultMinListRowHeight, 0)
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Changing the ´separatorStyle´ to ´.none´ only removed the Line but left the space.
Is there an extra ´hidden´ view for the Lists row or for the Separator between the rows?
How can this be controlled?
Would be using ScrollView instead of a List a good solution?
ScrollView(.horizontal, showsIndicators: true)
{
//List {
ForEach(data, id: \.self)
{ item in
HStack{
Text("\(item)")
Spacer()
}
Does it also work for a large dataset?
Well, actually no surprise - .separatorStyle = .none works correctly. I suppose you confused text background with cell background - they are changed by different modifiers. Please find below tested & worked code (Xcode 11.2 / iOS 13.2)
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.background(Color.yellow) // text background
.listRowBackground(Color.blue) // cell background
}
}
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Update:
it's not possible to avoid the blue space between the yellow Texts?
Technically yes, it is possible, however for demo it is used hardcoded values and it is not difficult to fit some, while to calculate this dynamically might be challenging... anyway, here it is
it needs combination of stack for compression, content padding for resistance, and environment for limit:
List {
ForEach(data, id: \.self)
{ item in
HStack { // << A
Text("\(item)")
.padding(.vertical, 2) // << B
}
.listRowBackground(Color.blue)
.background(Color.yellow)
.frame(height: 12) // << C
}
}
.environment(\.defaultMinListRowHeight, 12) // << D
I do it the easy SwiftUI way:
struct ContentView: View {
init() {
UITableView.appearance().separatorStyle = .none
}
var body: some View {
List {
ForEach(0..<10){ item in
Color.green
}
.listRowInsets( EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) )
}
}
}
Reduce row spacing is really tricky, try
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.largeTitle)
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
It use ScrollView instead of List and negative padding.
I didn't find any solution based on List, we have to ask Apple to publish xxxxStyle protocols and underlying structures.
UPDATE
What about this negative padding value? For sure it depends on height of our row content and unfortunately on SwiftUI layout strategy. Lets try some more dynamic content! (we use zero padding to demostrate the problem to solve)
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.system(size: item))
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
//.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
Clearly the row spacing is not fixed value! We have to calculate it for every row separately.
Next code snippet demonstrate the basic idea. I used global dictionary (to store height and position of each row) and tried to avoid any high order functions and / or some advanced SwiftUI technic, so it is easy to see the strategy. The required paddings are calculated only once, in .onAppear closure
import SwiftUI
var _p:[Int:(CGFloat, CGFloat)] = [:]
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
#State var space: [CGFloat] = []
func spc(item: CGFloat)->CGFloat {
if let d = data.firstIndex(of: item) {
return d < space.count ? space[d] : 0
} else {
return 0
}
}
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)")
.font(.system(size: item))
.background(Color.yellow)
}
.background(
GeometryReader { proxy->Color in
if let i = self.data.firstIndex(of: item) {
_p[i] = (proxy.size.height, proxy.frame(in: .global).minY)
}
return Color.green
}
)
.padding(.leading, 5)
.padding(.bottom, -self.spc(item: item))
.frame(maxWidth: .infinity)
}.onAppear {
var arr:[CGFloat] = []
_p.keys.sorted(by: <).forEach { (i) in
let diff = (_p[i + 1]?.1 ?? 0) - (_p[i]?.1 ?? 0) - (_p[i]?.0 ?? 0)
if diff < 0 {
arr.append(0)
} else {
arr.append(diff)
}
}
self.space = arr
}
}
}
}
}
Running the code I've got