Layout a view's vertical position inside Zstack in SwiftUI

493 views Asked by At

I'm facing some challenges to layout this prototype in SwiftUI.

Example

I used ZStack to layout the views. But my main problem is to position the profile image view's first half on the top the red background and the other half below to it. I used .offset() with some static value to layout as described. Here is what I tried so for

struct ProfileView: View {
    var body: some View {
        ZStack(alignment: .top) {
            Rectangle()
                .foregroundColor(.clear)
                .frame(width: 249, height: 291)
                .overlay(RoundedRectangle(cornerRadius: 0).stroke(.gray, lineWidth: 1))
            Rectangle()
                .frame(width: 243, height: 85)
                .foregroundColor(.red)
                .padding([.leading, .trailing, .top], 3)
            VStack(alignment: .center, spacing: 12) {
                VStack(alignment: .center, spacing: 12) {
                    Image(systemName: "person.crop.circle.fill")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: 89, height: 89)
                        .background(Color.white)
                        .foregroundColor(.gray.opacity(0.2))
                        .clipShape(Circle())
                        .overlay(Circle().stroke(Color.white, lineWidth: 4))
                    Text("John Doe")
                        .font(.system(size: 16))
                        .foregroundColor(.black)
                }
                .padding(.bottom, 24)
                VStack(spacing: 0) {
                    Text("JOB")
                        .font(.system(size: 12).bold())
                    Text("Developer")
                        .font(.system(size: 12))
                }
                .frame(width: 200, height: 59)
                .background(Color.gray.opacity(0.2))
            }
            .offset(y: 42.5)
        }
    }
}

I don't want to use this static offset instead I want to use something like GeometryReader along with position as dynamic solution. I tried GeometryReader inside ZStack but it scatters the layout.

Can someone help me to fix this? Thanks in advance.

2

There are 2 answers

1
Benzy Neez On BEST ANSWER

The content in this view is essentially arranged vertically, so I would suggest, an easier way to achieve the layout is to use a VStack instead of a ZStack. Spacing can be achieved with padding, instead of offsets. For sections containing text, I would also use padding instead of fixed vertical height (if the overall height can be flexible), because this will adapt automatically to a larger text size if the user has chosen to use this on their device.

The picture in the circle can be positioned quite easily by adding padding below the red rectangle and then using an overlay with alignment: .bottom.

Like this:

let circleDiameter: CGFloat = 89

var body: some View {
    VStack(spacing: 12) {
        Color.red
            .frame(height: 85)
            .padding(.bottom, circleDiameter / 2)
            .overlay(alignment: .bottom) {
                Image(systemName: "person.crop.circle.fill")
                    .resizable()
                    .scaledToFill()
                    .frame(width: circleDiameter, height: circleDiameter)
                    .background(Color.white)
                    .foregroundColor(.gray.opacity(0.2))
                    .clipShape(Circle())
                    .overlay(Circle().stroke(Color.white, lineWidth: 4))
            }
        Text("John Doe")
            .font(.system(size: 16))
            .foregroundColor(.black)
        VStack(spacing: 0) {
            Text("JOB")
                .font(.system(size: 12).bold())
            Text("Developer")
                .font(.system(size: 12))
        }
        .frame(width: 200)
        .padding(.vertical, 15)
        .background(Color.gray.opacity(0.2))
        .padding(.top, 24)
        .padding(.bottom, 31)
    }
    .padding(4)
    .frame(width: 250)
    .border(.gray)
}

EDIT Since the red background is exactly aligned with the middle of the circle, you could also achieve the layout by putting the image in the foreground and using a split background behind it, like this:

VStack(spacing: 28) {
    Image(systemName: "person.crop.circle.fill")
        .resizable()
        .scaledToFill()
        .frame(width: 89, height: 89)
        .background(Color.white)
        .foregroundColor(.gray.opacity(0.2))
        .clipShape(Circle())
        .overlay(Circle().stroke(Color.white, lineWidth: 4))
        .padding(40)
        .frame(maxWidth: .infinity)
        .background {
            VStack(spacing: 0) {
                Color.red
                Text("John Doe")
                    .font(.system(size: 16))
                    .foregroundColor(.black)
                    .padding(.bottom, 8)
                    .frame(maxHeight: .infinity, alignment: .bottom)
            }
        }
    VStack(spacing: 0) {
        Text("JOB")
            .font(.system(size: 12).bold())
        Text("Developer")
            .font(.system(size: 12))
    }
    .frame(width: 200)
    .padding(.vertical, 15)
    .background(Color.gray.opacity(0.2))
    .padding(.bottom, 31)
}
.padding(4)
.frame(width: 250)
.border(.gray)

With this approach, the red background can be positioned without needing to know the size of the circle. But otherwise, I wouldn't say that either technique is necessarily better than the other, they are just two ways of achieving the same thing.

0
Mojtaba Hosseini On

You can get any child size in 3 simple steps:

  1. Define a ChildSizeReader to bind the size later:
struct ChildSizeReader<Content: View>: View {
    @Binding var size: CGSize
    let content: () -> Content
    var body: some View {
        content()
            .background(GeometryReader { Color.clear.preference(key: SizePreferenceKey.self, value: $0.size) })
            .onPreferenceChange(SizePreferenceKey.self) { size = $0 }
    }
}

struct SizePreferenceKey: PreferenceKey {
    static var defaultValue = CGSize.zero
    static func reduce(value _: inout Value, nextValue: () -> Value) { }
}
  1. Define a simple state to keep the size:
@State var profileSize = CGSize.zero
  1. Surround the child you want (the profile image in your case) in that:
ChildSizeReader(size: $profileSize) {
    Image(systemName: "person.crop.circle.fill") //  You need the size of this view
        ,,,
}
Text("John Doe")
,,,

Now you can use the size where you need:

.offset(y: profileSize.height/2)

Done!