How Do I get uniform view size when using Image and SFSymbols in SwiftUI? - swift

Here is my View:
I want all boxes (the red rectangles) to be the same size (heights all equal to each other and widths all equal to each other). They don't need to be square.
When I create views using Image(systemname:) they have different intrinsic sizes. How do I make them the same size without hard-coding the size.
struct InconsistentSymbolSizes: View {
let symbols = [ "camera", "comb", "diamond", "checkmark.square"]
var body: some View {
HStack(spacing: 0) {
ForEach(Array(symbols), id: \.self) { item in
VStack {
Image(systemName: item).font(.largeTitle)
}
.padding()
.background(.white)
.border(.red)
}
}
.border(Color.black)
}
}

If you want to normalize the sizes, you could use a PreferenceKey to measure the largest size and make sure that all of the other sizes expand to that:
struct ContentView: View {
let symbols = [ "camera", "comb", "diamond", "checkmark.square"]
#State private var itemSize = CGSize.zero
var body: some View {
HStack(spacing: 0) {
ForEach(Array(symbols), id: \.self) { item in
VStack {
Image(systemName: item).font(.largeTitle)
}
.padding()
.background(GeometryReader {
Color.clear.preference(key: ItemSize.self,
value: $0.frame(in: .local).size)
})
.frame(width: itemSize.width, height: itemSize.height)
.border(.red)
}.onPreferenceChange(ItemSize.self) {
itemSize = $0
}
}
.border(Color.black)
}
}
struct ItemSize: PreferenceKey {
static var defaultValue: CGSize { .zero }
static func reduce(value: inout Value, nextValue: () -> Value) {
let next = nextValue()
value = CGSize(width: max(value.width,next.width),
height: max(value.height,next.height))
}
}

Related

SwiftUI - Dynamic LazyHGrid row height

I'm creating vertical layout which has scrollable horizontal LazyHGrid in it. The problem is that views in LazyHGrid can have different heights (primarly because of dynamic text lines) but the grid always calculates height of itself based on first element in grid:
What I want is changing size of that light red rectangle based on visible items, so when there are smaller items visible it should look like this:
and when there are bigger items it should look like this:
This is code which results in state on the first image:
struct TestView: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem()],
alignment: .top,
spacing: 16
) {
Color.red
.frame(width: 64, height: 24)
ForEach(Array(0...10), id: \.self) { value in
Color.red
.frame(width: 64, height: CGFloat.random(in: 32...92))
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}
}
}
Something similar what I want can be achieved by this:
extension View {
func readSize(edgesIgnoringSafeArea: Edge.Set = [], onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
SwiftUI.Color.clear
.preference(key: ReadSizePreferenceKey.self, value: geometryProxy.size)
}.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
)
.onPreferenceChange(ReadSizePreferenceKey.self) { size in
DispatchQueue.main.async { onChange(size) }
}
}
}
struct ReadSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct Size: Equatable {
var height: CGFloat
var isValid: Bool
}
struct TestView: View {
#State private var sizes = [Int: Size]()
#State private var height: CGFloat = 32
static let values: [(Int, CGFloat)] =
(0...3).map { ($0, CGFloat(32)) }
+ (4...10).map { ($0, CGFloat(92)) }
var body: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem(.fixed(height))],
alignment: .top,
spacing: 16
) {
ForEach(Array(Self.values), id: \.0) { value in
Color.red
.frame(width: 300, height: value.1)
.readSize { sizes[value.0]?.height = $0.height }
.onAppear {
if sizes[value.0] == nil {
sizes[value.0] = Size(height: .zero, isValid: true)
} else {
sizes[value.0]?.isValid = true
}
}
.onDisappear { sizes[value.0]?.isValid = false }
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}.onChange(of: sizes) { sizes in
height = sizes.filter { $0.1.isValid }.map { $0.1.height }.max() ?? 32
}
}
}
... but as you see its kind of laggy and a little bit complicated, isn't there better solution? Thank you everyone!
The height of a row in a LazyHGrid is driven by the height of the tallest cell. According to the example you provided, the data source will only show a smaller height if it has only a small size at the beginning.
Unless the first rendering will know that there are different heights, use the larger value as the height.
Is your expected UI behaviour that the height will automatically switch? Or use the highest height from the start.

Complex alignment situation in SwiftUI

I have a complicated alignment situation, which I was hoping there was some solution with alignmentGuides, but I can't figure it out.
I need to align the following list of entries (in a mono font), such that as a group it is horizontally centered.
But also that it is positioned the same way, and aligned the same way along the leading edge, even if one of the entries in a row is longer:
I don't want to hardcode any sizing or positioning values.
Here's some simple code to replicate:
struct TestView: View {
let values: [(String, String)] = [
("03", "30.123"),
("02", "33.222"),
("01", "completed")
]
var body: some View {
LazyVStack {
ForEach(values, id: \.1) { tuple in
HStack(spacing: 24) {
Text(tuple.0)
Text(tuple.1)
}
}
}
.frame(width: 350, height: 250) // to simulate outer container dimensions
}
}
Here is a simple way may it help:
struct ContentView: View {
var body: some View {
TestView()
}
}
struct TestView: View {
let values: [(String, String)] = [
("03", "30.123"),
("02", "33.222"),
("01", "completed")
]
var body: some View {
HStack(spacing: 24) {
VStack { ForEach(values, id: \.1) { tuple in Text(tuple.0) } }
VStack(alignment: .leading) { ForEach(values, id: \.1) { tuple in
Text("00.000")
.foregroundColor(Color.clear)
.overlay(Text(tuple.1).lineLimit(1).fixedSize(), alignment: .leading)
} }
}
.font(Font.system(.body, design: .monospaced))
.padding(5.0)
.background(Color.pink.opacity(0.5).cornerRadius(5.0))
}
}
I found a way to solve this, though it seems quite convoluted to me. Posting here, but still would love a better solution.
The idea was to use a placeholder with dummy content (relying on the fact that this was a monospaced font) and using anchorPreference to align around its leading edge.
struct LeadingPreferenceKey: PreferenceKey {
static var defaultValue: Anchor<CGPoint>? = nil
static func reduce(value: inout Anchor<CGPoint>?,
nextValue: () -> Anchor<CGPoint>?) {
value = nextValue()
}
}
Then, set that in anchorPreference, and capture it in overlayPreferenceValue and position with offset(x:)
LazyVStack(alignment: .center) {
row(("00", "00.000")) // placeholder that's center-positioned
.opacity(0)
.anchorPreference(key: LeadingPreferenceKey.self,
value: .leading, , transform: { $0 })
}
.overlayPreferenceValue(LeadingPreferenceKey.self) { leading in
GeometryReader { geo in
LazyVStack(alignment: .leading) {
ForEach(values, id: \.1) { tuple in
row(tuple)
}
}
.offset(x: leading.map { geo[$0].x } ?? 0)
}
}
func row(_ tuple: (String, String)) -> some View {
HStack(spacing: 24) {
Text(tuple.0)
Text(tuple.1)
}
}
Here is a solution that will provide your alignment as well as control of the width of your columns. It is a separate view that takes a tuple and returns two VStacks in an HStack contained by width:
struct TwoItemLeadingAlignedColumn: View {
let firstColumnItems: [String]
let secondColumnItems: [String]
let width: CGFloat?
init(items: [(String, String)], width: CGFloat? = nil) {
firstColumnItems = items.map { $0.0 }
secondColumnItems = items.map { $0.1 }
self.width = width
}
var body: some View {
GeometryReader { geometry in
HStack {
VStack(alignment: .center) {
ForEach(firstColumnItems, id: \.self) {item in
Text(item)
}
}
Spacer()
.frame(width: geometry.size.width * 0.25)
VStack(alignment: .leading) {
ForEach(secondColumnItems, id: \.self) {item in
Text(item)
}
}
Spacer()
}
.frame(width: width)
}
}
}
struct TestView: View {
let values: [(String, String)] = [
("03", "30.123"),
("02", "33.222"),
("01", "completed")
]
var body: some View {
TwoItemLeadingAlignedColumn(items: values, width: 150)
.frame(width: 350, height: 250) // to simulate outer container dimensions
}
}
This answer will work whether or not you are using a monospaced font.
Update: Center first column

Custom Segmented Controller SwiftUI Frame Issue

I would like to create a custom segmented controller in SwiftUI, and I found one made from this post. After slightly altering the code and putting it into my ContentView, the colored capsule would not fit correctly.
Here is an example of my desired result:
This is the result when I use it in ContentView:
CustomPicker.swift:
struct CustomPicker: View {
#State var selectedIndex = 0
var titles = ["Item #1", "Item #2", "Item #3", "Item #4"]
private var colors = [Color.red, Color.green, Color.blue, Color.purple]
#State private var frames = Array<CGRect>(repeating: .zero, count: 4)
var body: some View {
VStack {
ZStack {
HStack(spacing: 4) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.titles[index])
.foregroundColor(.black)
.font(.system(size: 16, weight: .medium, design: .default))
.bold()
}.padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)).background(
GeometryReader { geo in
Color.clear.onAppear { self.setFrame(index: index, frame: geo.frame(in: .global)) }
}
)
}
}
.background(
Capsule().fill(
self.colors[self.selectedIndex].opacity(0.4))
.frame(width: self.frames[self.selectedIndex].width,
height: self.frames[self.selectedIndex].height, alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
, alignment: .leading
)
}
.animation(.default)
.background(Capsule().stroke(Color.gray, lineWidth: 3))
}
}
func setFrame(index: Int, frame: CGRect) {
self.frames[index] = frame
}
}
ContentView.swift:
struct ContentView: View {
#State var itemsList = [Item]()
func loadData() {
if let url = Bundle.main.url(forResource: "Data", withExtension: "json") {
do {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let jsonData = try decoder.decode(Response.self, from: data)
for post in jsonData.content {
self.itemsList.append(post)
}
} catch {
print("error:\(error)")
}
}
}
var body: some View {
NavigationView {
VStack {
Text("Item picker")
.font(.system(.title))
.bold()
CustomPicker()
Spacer()
ScrollView {
VStack {
ForEach(itemsList) { item in
ItemView(text: item.text, username: item.username)
.padding(.leading)
}
}
}
.frame(height: UIScreen.screenHeight - 224)
}
.onAppear(perform: loadData)
}
}
}
Project file here
The problem with the code as-written is that the GeometryReader value is only sent on onAppear. That means that if any of the views around it change and the view is re-rendered (like when the data is loaded), those frames will be out-of-date.
I solved this by using a PreferenceKey instead, which will run on each render:
struct CustomPicker: View {
#State var selectedIndex = 0
var titles = ["Item #1", "Item #2", "Item #3", "Item #4"]
private var colors = [Color.red, Color.green, Color.blue, Color.purple]
#State private var frames = Array<CGRect>(repeating: .zero, count: 4)
var body: some View {
VStack {
ZStack {
HStack(spacing: 4) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.titles[index])
.foregroundColor(.black)
.font(.system(size: 16, weight: .medium, design: .default))
.bold()
}
.padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
.measure() // <-- Here
.onPreferenceChange(FrameKey.self, perform: { value in
self.setFrame(index: index, frame: value) //<-- this will run each time the preference value changes, will will happen any time the frame is updated
})
}
}
.background(
Capsule().fill(
self.colors[self.selectedIndex].opacity(0.4))
.frame(width: self.frames[self.selectedIndex].width,
height: self.frames[self.selectedIndex].height, alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
, alignment: .leading
)
}
.animation(.default)
.background(Capsule().stroke(Color.gray, lineWidth: 3))
}
}
func setFrame(index: Int, frame: CGRect) {
print("Setting frame: \(index): \(frame)")
self.frames[index] = frame
}
}
struct FrameKey : PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
extension View {
func measure() -> some View {
self.background(GeometryReader { geometry in
Color.clear
.preference(key: FrameKey.self, value: geometry.frame(in: .global))
})
}
}
Note that the original .background call was taken out and was replaced with .measure() and .onPreferenceChange -- look for where the //<-- Here note is.
Besides that and the PreferenceKey and View extension, nothing else is changed.

SwiftUI set position to center of different view

I have two different views, one red rect and one black rect that is always positioned on the bottom of the screen. When I click the red rect it should position itself inside the other rect.
Currently the red rect is positioned statically: .position(x: self.tap ? 210 : 50, y: self.tap ? 777 : 50). Is there a way to replace the 210 and 777 dynamically to the position of the black rects center position?
I know that I can use the GeometryReader to get the views size, but how do I use that size to position a different view? Would this even be the right way?
struct ContentView: View {
#State private var tap = false
var body: some View {
ZStack {
VStack {
Spacer()
RoundedRectangle(cornerRadius: 10)
.frame(maxWidth: .infinity, maxHeight: 50, alignment: .center)
}
.padding()
VStack {
ZStack {
Rectangle()
.foregroundColor(.red)
Text("Click me")
.fontWeight(.light)
.foregroundColor(.white)
}
.frame(width: 50, height: 50)
.position(x: self.tap ? 210 : 50, y: self.tap ? 777 : 50)
.onTapGesture {
withAnimation {
self.tap.toggle()
}
}
}
}
}
}
First define some structure where to store .center position of some View
struct PositionData: Identifiable {
let id: Int
let center: Anchor<CGPoint>
}
The build-in mechanism to save such data and expose them to parent View is to set / read (or react) on values which conforms to PreferenceKey protocol.
struct Positions: PreferenceKey {
static var defaultValue: [PositionData] = []
static func reduce(value: inout [PositionData], nextValue: () -> [PositionData]) {
value.append(contentsOf: nextValue())
}
}
To be able to read the center positions of View we can use well known and widely discussed GeometryReader. I define my PositionReader as a View and here we can simply save its center position in our preferences for further usage. There is no need to translate the center to different coordinate system. To identify the View its tag value must be saved as well
struct PositionReader: View {
let tag: Int
var body: some View {
// we don't need geometry reader at all
//GeometryReader { proxy in
Color.clear.anchorPreference(key: Positions.self, value: .center) { (anchor) in
[PositionData(id: self.tag, center: anchor)]
}
//}
}
}
To demonstrate how to use all this together see next simple application (copy - paste - run)
import SwiftUI
struct PositionData: Identifiable {
let id: Int
let center: Anchor<CGPoint>
}
struct Positions: PreferenceKey {
static var defaultValue: [PositionData] = []
static func reduce(value: inout [PositionData], nextValue: () -> [PositionData]) {
value.append(contentsOf: nextValue())
}
}
struct PositionReader: View {
let tag: Int
var body: some View {
Color.clear.anchorPreference(key: Positions.self, value: .center) { (anchor) in
[PositionData(id: self.tag, center: anchor)]
}
}
}
struct ContentView: View {
#State var tag = 0
var body: some View {
ZStack {
VStack {
Color.green.background(PositionReader(tag: 0))
.onTapGesture {
self.tag = 0
}
HStack {
Rectangle()
.foregroundColor(Color.red)
.aspectRatio(1, contentMode: .fit)
.background(PositionReader(tag: 1))
.onTapGesture {
self.tag = 1
}
Rectangle()
.foregroundColor(Color.red)
.aspectRatio(1, contentMode: .fit)
.background(PositionReader(tag: 2))
.onTapGesture {
self.tag = 2
}
Rectangle()
.foregroundColor(Color.red)
.aspectRatio(1, contentMode: .fit)
.background(PositionReader(tag: 3))
.onTapGesture {
self.tag = 3
}
}
}
}.overlayPreferenceValue(Positions.self) { preferences in
GeometryReader { proxy in
Rectangle().frame(width: 50, height: 50).position( self.getPosition(proxy: proxy, tag: self.tag, preferences: preferences))
}
}
}
func getPosition(proxy: GeometryProxy, tag: Int, preferences: [PositionData])->CGPoint {
let p = preferences.filter({ (p) -> Bool in
p.id == tag
})[0]
return proxy[p.center]
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The code is almost self explanatory, we use .background(PositionReader(tag:)) to save the center position of View (this could be avoided by applying .anchorPreference directly on the View) and
.overlayPreferenceValue(Positions.self) { preferences in
GeometryReader { proxy in
Rectangle().frame(width: 50, height: 50).position( self.getPosition(proxy: proxy, tag: self.tag, preferences: preferences))
}
}
is used to create small black rectangle which will position itself at center of other Views. Just tap anywhere in green or red rectangles, and the black one will move immediately :-)
Here is view of this sample application running.
Here is possible approach (with a bit simplified your initial snapshot and added some convenient View extension).
Tested with Xcode 11.2 / iOS 13.2
extension View {
func rectReader(_ binding: Binding<CGRect>, in space: CoordinateSpace) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: space)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
struct ContentView: View {
#State private var tap = false
#State private var bottomRect: CGRect = .zero
var body: some View {
ZStack(alignment: .bottom) {
RoundedRectangle(cornerRadius: 10)
.frame(maxWidth: .infinity, maxHeight: 50, alignment: .center)
.padding()
.rectReader($bottomRect, in: .named("board"))
Rectangle()
.foregroundColor(.red)
.overlay(Text("Click me")
.fontWeight(.light)
.foregroundColor(.white)
)
.frame(width: 50, height: 50)
.position(x: self.tap ? bottomRect.midX : 50,
y: self.tap ? bottomRect.midY : 50)
.onTapGesture {
withAnimation {
self.tap.toggle()
}
}
}.coordinateSpace(name: "board")
}
}

How to right-align item labels in a custom SwiftUI form on AppKit?

I have the following Cocoa form:
struct Canvas: PreviewProvider {
static var previews: some View {
VStack {
HStack(alignment: .firstTextBaseline) {
Text("Endpoint:")
TextField("https://localhost:8080/api", text: .constant(""))
}
Divider()
HStack(alignment: .firstTextBaseline) {
Text("Path:")
TextField("/todos", text: .constant(""))
}
Spacer()
}
.padding()
.previewLayout(.fixed(width: 280, height: 200))
}
}
This panel looks nice but I’d like to right-align “Endpoint:” and “Path:” labels:
So I apply a custom horizontal alignment:
struct Canvas: PreviewProvider {
static var previews: some View {
VStack(alignment: .label) {
HStack(alignment: .firstTextBaseline) {
Text("Endpoint:").alignmentGuide(.label) { $0[.trailing] }
TextField("https://localhost:8080/api", text: .constant(""))
}
Divider()
HStack(alignment: .firstTextBaseline) {
Text("Path:").alignmentGuide(.label) { $0[.trailing] }
TextField("/todos", text: .constant(""))
}
Spacer()
}
.padding()
.previewLayout(.fixed(width: 280, height: 200))
}
}
extension HorizontalAlignment {
private enum Label: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.leading]
}
}
static let label: HorizontalAlignment = .init(Label.self)
}
Results are not what I need however:
There is no documentation, please help.
I don't believe alignment guides will work here in their current implementation. After playing with them a bit, it seems that they size their children based on the container's given size and then align each child based on the guide. This leads to the weird behavior you were seeing.
Below I show 3 different techniques that will allow you to get your desired results, in order of complexity. Each has its applications outside of this specific example.
The last (label3()) will be the most reliable for longer forms.
struct ContentView: View {
#State var sizes: [String:CGSize] = [:]
var body: some View {
VStack {
HStack(alignment: .firstTextBaseline) {
self.label3("Endpoint:")
TextField("https://localhost:8080/api", text: .constant(""))
}
Divider()
HStack(alignment: .firstTextBaseline) {
self.label3("Path:")
TextField("/todos", text: .constant(""))
}
}
.padding()
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.sizes = preferences
}
}
func label1(_ text: String) -> some View {
Text(text) // Use a minimum size based on your best guess. Look around and you'll see that many macOS apps actually lay forms out like this because it's simple to implement.
.frame(minWidth: 100, alignment: .trailing)
}
func label2(_ text: String, sizer: String = "Endpoint:") -> some View {
ZStack(alignment: .trailing) { // Use dummy content for sizing based on the largest expected item. This can be great when laying out icons and you know ahead of time which will be the biggest.
Text(sizer).opacity(0.0)
Text(text)
}
}
func label3(_ text: String) -> some View {
Text(text) // Use preferences and save the size of each label
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: [text : proxy.size])
}
)
.frame(minWidth: self.sizes.values.map { $0.width }.max() ?? 0.0, alignment: .trailing)
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = [String:CGSize]
static var defaultValue: Value = [:]
static func reduce(value: inout Value, nextValue: () -> Value) {
let next = nextValue()
for (k, v) in next {
value[k] = v
}
}
}
Here's a screenshot of the results with label2 or label3.
Using XCode 13.1 and targeting MacOS 12 you can achieve the desired result quite easily by adding a "Form" element:
struct Canvas: PreviewProvider {
static var previews: some View {
Form {
TextField("Endpoint:", text: .constant(""))
Divider()
TextField("Path:", text: .constant(""))
}
.previewLayout(.fixed(width: 280, height: 200))
}
}
The divider is not covering the area of the labels, but this is intended by Apple. Also, I haven't found out quickly how to add the placeholders to the text fields.