Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI Login Page Layout

I am exploring SwiftUI as I am trying to build a login view and now I am facing a problem

This is what I am trying to achieve:

What I already achieved

As you can see I already reached this point but I don't like my implementation

struct ContentView : View {
@State var username: String = ""
var body: some View {
    VStack(alignment: .leading) {
        Text("Login")
            .font(.title)
            .multilineTextAlignment(.center)
            .lineLimit(nil)
            Text("Please")
                .font(.subheadline)

        HStack {
            VStack (alignment: .leading, spacing: 20) {
                Text("Username: ")
                Text("Password: ")

            }
            VStack {
                TextField($username, placeholder: Text("type something here..."))
                .textFieldStyle(.roundedBorder)
                TextField($username, placeholder: Text("type something here..."))
                    .textFieldStyle(.roundedBorder)
                }
            }
        }.padding()
    }
}

Because in order to make the username and password text aligned exactly in the middle of the textfield, I had to put literal spacing value of 20 in the VStack which I don't like because most probably It won't work on different device sizes.

Anyone sees a better way to achieve the same result?

Thanks

like image 805
Mostafa Mohamed Raafat Avatar asked Dec 02 '22 10:12

Mostafa Mohamed Raafat


2 Answers

We're going to implement two new View modifier methods so that we can write this:

struct ContentView: View {
    @State var labelWidth: CGFloat? = nil
    @State var username = ""
    @State var password = ""

    var body: some View {
        VStack {
            HStack {
                Text("User:")
                    .equalSizedLabel(width: labelWidth, alignment: .trailing)
                TextField("User", text: $username)
            }
            HStack {
                Text("Password:")
                    .equalSizedLabel(width: labelWidth, alignment: .trailing)
                SecureField("Password", text: $password)
            }
        }
        .padding()
        .textFieldStyle(.roundedBorder)
        .storeMaxLabelWidth(in: $labelWidth)
    }
}

The two new modifiers are equalSizedLabel(width:alignment:) and storeMaxLabelWidth(in:).

The equalSizedLabel(width:alignment) modifier does two things:

  1. It applies the width and alignment to its content (the Text(“User:”) and Text(“Password:”) views).
  2. It measures the width of its content and passes that up to any ancestor view that wants it.

The storeMaxLabelWidth(in:) modifier receives those widths measured by equalSizedLabel and stores the maximum width in the $labelWidth binding we pass to it.

So, how do we implement these modifiers? How do we pass a value from a descendant view up to an ancestor? In SwiftUI, we do this using the (currently undocumented) “preference” system.

To define a new preference, we define a type conforming to PreferenceKey. To conform to PreferenceKey, we have to define the default value for our preference, and we have to define how to combine the preferences of multiple subviews. We want our preference to be the maximum width of all the labels, so the default value is zero and we combine preferences by taking the maximum. Here's the PreferenceKey we'll use:

struct MaxLabelWidth: PreferenceKey {
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = max(value, nextValue())
    }
}

The preference modifier function sets a preference, so we can say .preference(key: MaxLabelWidth.self, value: width) to set our preference, but we have to know what width to set. We need to use a GeometryReader to get the width, and it's a little tricky to do properly, so we'll wrap it up in a ViewModifier like this:

extension MaxLabelWidth: ViewModifier {
    func body(content: Content) -> some View {
        return content
            .background(GeometryReader { proxy in
                Color.clear
                    .preference(key: Self.self, value: proxy.size.width)
            })
    }
}

What's happening above is we attach a background View to the content, because a background is always the same size as the content it's attached to. The background View is a GeometryReader, which (via the proxy) provides access to its own size. We have to give the GeometryReader its own content. Since we don't actually want to show a background behind the original content, we use Color.clear as the GeometryReader's content. Finally, we use the preference modifier to store the width as the MaxLabelWidth preference.

Now have can define the equalSizedLabel(width:alignment:) and storeMaxLabelWidth(in:) modifier methods:

extension View {
    func equalSizedLabel(width: CGFloat?, alignment: Alignment) -> some View {
        return self
            .modifier(MaxLabelWidth())
            .frame(width: width, alignment: alignment)
    }
}

extension View {
    func storeMaxLabelWidth(in binding: Binding<CGFloat?>) -> some View {
        return self.onPreferenceChange(MaxLabelWidth.self) {
            binding.value = $0
        }
    }
}

Here's the result:

result screenshot

like image 126
rob mayoff Avatar answered Dec 19 '22 20:12

rob mayoff


You can use Spacers alongside with fixedSize modifier for height. You should set set heights of any row's object in order to achieve exact table style view:

struct ContentView : View {

    private let height: Length = 32

    @State var username: String = ""
    var body: some View {
        VStack(alignment: .leading) {
            Text("Login")
                .font(.title)
                .multilineTextAlignment(.center)
                .lineLimit(nil)
            Text("Please")
                .font(.subheadline)

            HStack {
                VStack (alignment: .leading) {
                    Text("Username: ") .frame(height: height)
                    Spacer()
                    Text("Password: ") .frame(height: height)
                }
                VStack {
                    TextField($username, placeholder: Text("type something here..."))
                        .textFieldStyle(.roundedBorder)
                        .frame(height: height)
                    Spacer()
                    TextField($username, placeholder: Text("type something here..."))
                        .textFieldStyle(.roundedBorder)
                        .frame(height: height)
                }
                }
                .fixedSize(horizontal: false, vertical: true)

            }
            .padding()
    }
}

Note that setting height on TextField does not effect it's height directly, but it will just set the height of it's content text's height.

like image 22
Mojtaba Hosseini Avatar answered Dec 19 '22 22:12

Mojtaba Hosseini