swiftui 开发之旅:三大 Stack 构建布局

5,339 阅读11分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

swiftui 中最常用的构建布局的视图当属:VStack、HStack、ZStack 莫属。

image.png

借助这 3 个 Stack,我们能构建出,上下,左右,等宽,叠放,或者是类似于前端的 Flex 布局;下面让我们来看看这 3 个利器的具体使用。

VStack

VStack 用于垂直排列视图。

struct Test: View {
    var body: some View {
        VStack{
            Text("高级会员").padding(5).font(.headline).foregroundColor(Color(hex: "#8E6A30"))
            Divider()
            Text("无限制账户").padding(.vertical, 5)
            Text("币种切换,自动计算汇率").padding(.vertical, 5)
            Text("价格自动更新").padding(.vertical, 5)
            Text("云端数据同步").padding(.vertical, 5)
            Text("专属背景皮肤").padding(.vertical, 5)
        }
        .foregroundColor(Color(hex: "#8E6A30"))
    }
}

在上面的代码中,我们将文本在垂直线上排列,同时给 VStack 添加了 foregroundColor 修饰符,这会使 VStack 视图内的文本使用同一种颜色。

image.png

对齐方向

VStack 还支持对内部视图进行对齐控制。

VStack 支持三种对齐方式,默认使用居中对齐:

  • .leading:左对齐
  • .trailing:右对齐
  • .center:居中对齐

需要注意的是,VStack 设置的对齐方向是水平方向

struct Test: View {
    var body: some View {
        VStack(alignment: .leading){
            Text("高级会员").padding(5).font(.headline).foregroundColor(Color(hex: "#8E6A30"))
            Divider()
            Text("无限制账户").padding(.vertical, 5)
            Text("币种切换,自动计算汇率").padding(.vertical, 5)
            Text("价格自动更新").padding(.vertical, 5)
            Text("云端数据同步").padding(.vertical, 5)
            Text("专属背景皮肤").padding(.vertical, 5)
        }
        .foregroundColor(Color(hex: "#8E6A30"))
    }
}

image.png

设置间距

在上面例子中,我们对每个文本设置了垂直方向的 padding,也就是上下边距分别为 5 来使文本不至于紧挨在一起。但这种设置方式太多余繁琐,我们可以借助 VStack 的第二个属性 spacing 来设置内部子视图的上下间距。

struct Test: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 15){
            Text("高级会员").padding(5).font(.headline).foregroundColor(Color(hex: "#8E6A30"))
            Divider()
            Text("无限制账户")
            Text("币种切换,自动计算汇率")
            Text("价格自动更新")
            Text("云端数据同步")
            Text("专属背景皮肤")
        }
        .foregroundColor(Color(hex: "#8E6A30"))
    }
}

image.png

注意:这里 spacing 的值为 15,效果近似于 padding(.vertical, 5)。

HStack

HStack 用于将内部子视图排列在水平方向上。

import SwiftUI

struct Test: View {
    var body: some View {
        HStack{
            Text("高级会员").font(.headline).foregroundColor(Color(hex: "#8E6A30"))
            Divider()
            Text("无限制账户")
            Text("币种切换,自动计算汇率")
            Text("价格自动更新")
            Text("云端数据同步")
            Text("专属背景皮肤")
        }
        .foregroundColor(Color(hex: "#8E6A30"))
    }
}

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
    }
}

image.png

HStack 同样能够设置内部子视图的对齐方式和间距,但其设置的对齐方向和间距和 VStack 相反,是针对垂直方向的。

HStack 的 spacing 使用方式和 VStack 的一样,这里不再赘述。

HStack 支持五种对齐方式,默认使用居中对齐:

  • .top:左顶部对齐
  • .bottom:底部对齐
  • .center:居中对齐
  • .firstTextBaseline:基于第一行文本的基线对齐
  • .lastTextBaseline:基于最后一行文本的基线对齐

image.png

来看一个具体示例:

import SwiftUI

struct Test: View {
    var body: some View {
        VStack {
            HStack(alignment: .top){
                Text("顶部对齐").font(.headline).foregroundColor(Color(hex: "#8E6A30"))
                Divider()
                Text("无限制账户")
                Text("币种切换,自动计算汇率")
                Text("价格自动更新")
                    .padding(.vertical, 5)
                Text("云端数据同步")
                    .padding(.vertical, 5)
                Text("专属背景皮肤")
            }
            .foregroundColor(Color(hex: "#8E6A30"))
            HStack(alignment: .center){
                Text("居中对齐").font(.headline).foregroundColor(Color(hex: "#8E6A30"))
                Divider()
                Text("无限制账户")
                Text("币种切换,自动计算汇率")
                Text("价格自动更新")
                    .padding(.vertical, 5)
                Text("云端数据同步")
                    .padding(.vertical, 5)
                Text("专属背景皮肤")
            }
            .foregroundColor(Color(hex: "#8E6A30"))
            HStack(alignment: .bottom){
                Text("底部对齐").font(.headline).foregroundColor(Color(hex: "#8E6A30"))
                Divider()
                Text("无限制账户")
                Text("币种切换,自动计算汇率")
                Text("价格自动更新")
                    .padding(.vertical, 5)
                Text("云端数据同步")
                    .padding(.vertical, 5)
                Text("专属背景皮肤")
            }
            .foregroundColor(Color(hex: "#8E6A30"))
            HStack(alignment: .firstTextBaseline){
                Text("第一行文本基线对齐").font(.headline).foregroundColor(Color(hex: "#8E6A30"))
                Divider()
                Text("无限制账户")
                Text("币种切换,自动计算汇率")
                Text("价格自动更新")
                    .padding(.vertical, 5)
                Text("云端数据同步")
                    .padding(.vertical, 5)
                Text("专属背景皮肤")
            }
            .foregroundColor(Color(hex: "#8E6A30"))
            HStack(alignment: .lastTextBaseline){
                Text("最后一行文本基线对齐").font(.headline).foregroundColor(Color(hex: "#8E6A30"))
                Divider()
                Text("无限制账户")
                Text("币种切换,自动计算汇率")
                Text("价格自动更新")
                    .padding(.vertical, 5)
                Text("云端数据同步")
                    .padding(.vertical, 5)
                Text("专属背景皮肤")
            }
            .foregroundColor(Color(hex: "#8E6A30"))
        }
    }
}

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
    }
}

image.png

ZStack

ZStack 主要用于将内部子视图在 Z 轴上排列,其特点是对于内部的连续子视图,都会分配一个比前一个子视图优先级更高的 Z 轴值,也就是,越在后面出现的子视图,越会显示在“前”。

import SwiftUI

struct Test: View {
    let colors: [Color] =
        [.red, .orange, .yellow, .green, .blue, .purple]

    var body: some View {
        ZStack {
            ForEach(0..<colors.count) {
                Rectangle()
                    .fill(colors[$0])
                    .frame(width: 100, height: 100)
                    .offset(x: CGFloat($0) * 10.0,
                            y: CGFloat($0) * 10.0)
            }
        }
    }
}

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
    }
}

image.png

再看一个示例:

import SwiftUI

struct Test: View {
    let colors: [Color] =
        [.red, .orange, .yellow, .green, .blue, .purple]

    var body: some View {
          ZStack {
              Image(systemName: "gamecontroller.fill")
                  .resizable()
                  .aspectRatio(contentMode: .fit)
              HStack {
                  VStack(alignment: .leading) {
                      Text("Game Play")
                          .font(.headline)
                      Text("Go! Go! Go!")
                          .font(.subheadline)
                  }
                  Spacer()
              }
              .padding()
              .foregroundColor(.primary)
              .background(Color.primary
              .colorInvert()
              .opacity(0.75))
          }.background(Color.gray)
      }
}

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
    }
}

image.png

微信读书会员页面仿写

介绍完 3 个布局神器,下面让我们来做一个复杂点的示例。

下图是微信读书会员卡页面,让我们用 3个 Stack 来仿照这个页面。

2571663297324_.pic.jpg

  1. 先设置基本布局

会员卡页面的3个主要的布局内容包括:顶部导航、中间的滚动内容区域、底部固定区域,我们先把这3个区域的布局设置好。

import SwiftUI

struct Test: View {
    var body: some View {
        NavigationView {
            ZStack {
                Color(red: 39/255, green: 46/255, blue: 71/255).edgesIgnoringSafeArea(.all)
                ScrollView(showsIndicators: false) {
                }
            }
            .safeAreaInset(edge: .bottom) {
            }
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

NavigationView 用于设置导航栏;ZStack 用于设置底层的背景色,其他视图将会在背景色之上显示;safeAreaInset 用于设置底部固定区域。

  1. 主要修饰符

在开发的过程中我们会用到大量的 swiftui 修饰符,修饰符的使用顺序是会影响布局的结果的哦,下面是用到的一些主要修饰符:

  • .padding: 边距。
  • foregroundColor:设置视图颜色,比如文本、图标的颜色。
  • font:设置文字大小。
  • .frame:设置视图宽高。
  • .background:设置视图背景色。
  • .cornerRadius:设置圆角。
  • .safeAreaInset:设置一个安全区域,也就是固定区域。
  • .bold:设置文字字重,需要 ios16。
  • .navigationBarTitleDisplayMode:设置导航栏模式
  • .toolbar:自定义导航栏内容。
  • .navigationBarItems:设置导航栏左右两边按钮。
  • .overlay:这里用于设置圆角边框。
  1. 完整代码示例

直接上完整代码,详细内容请看代码内注释。

import SwiftUI

struct Test: View {
    var body: some View {
        NavigationView {
            ZStack {
                // 设置页面背景色
                Color(red: 39/255, green: 46/255, blue: 71/255).edgesIgnoringSafeArea(.all)
                ScrollView(showsIndicators: false)  {
                    VStack(alignment: .leading) {
                        VStack(alignment: .leading) {
                            Text("付费会员卡")
                                .bold()
                                .padding(.bottom, 4)
                                .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
                        }
                        // 填充中间空白区域,使文字上下靠边
                        Spacer()
                        HStack(alignment: .bottom) {
                            Text("3").bold().padding(.bottom, -4).font(.system(size: 24))
                            Text("天·9月27日到期").font(.system(size: 10))
                            // 空白区域填充,使文字居左
                            Spacer(minLength: 0)
                        }.foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
                    }
                    .padding()
                    .frame(height: 180)
                    // 会员卡背景色渐变
                    .background(RadialGradient(
                        gradient: Gradient(
                            colors: [
                                Color(red: 56/255, green: 81/255, blue: 116/255),
                                Color(red: 39/255, green: 46/255, blue: 71/255),
                                Color(red: 231/255, green: 200/255, blue: 153/255),
                                Color(red: 39/255, green: 46/255, blue: 71/255),
                            ]
                        ),
                        center: .center,
                        startRadius: 2,
                        endRadius: 650)
                    )
                    // 圆角边框
                    .overlay(
                        RoundedRectangle(cornerRadius: 12, style: .continuous)
                            .stroke(Color(red: 231/255, green: 200/255, blue: 153/255), lineWidth: 1)
                        
                    ).padding(.bottom, 10)
                    VStack(alignment: .leading) {
                        HStack(alignment: .top, spacing: 0) {
                            VStack(alignment: .leading, spacing: 20) {
                                HStack() {
                                    Image(systemName: "infinity")
                                    Text("付费会员卡").bold()
                                }.padding(.bottom, -5)
                                Divider().overlay(Color.gray)
                                Text("全场出版书畅读")
                                Text("全场有声书畅听")
                                Text("书架无上限")
                                Text("离线下载无上限")
                                Text("时长可兑换体验卡和书币")
                                Text("专属阅读背景和字体")
                            }
                            .padding()
                            .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
                            Spacer()
                            VStack(alignment: .leading, spacing: 20) {
                                HStack() {
                                    Image(systemName: "infinity")
                                    Text("体验卡")
                                }.padding(.bottom, -5)
                                Divider().overlay(Color.gray)
                                Text("部分出版书畅读")
                                Text("仅可收盘 AI 朗读")
                                Text("书架 500 本上限")
                                Text("每月可下载 3 本")
                                Text("仅可兑换体验卡")
                                Text("-")
                            }
                            .padding()
                            .foregroundColor(Color.gray)
                        }.font(.system(size: 12))
                    }
                    .background(Color(red: 47/255, green: 54/255, blue: 77/255))
                    .cornerRadius(12)
                }.padding([.top, .leading, .trailing])
            }
            // 设置一个底部固定区域,然后自定义其内部子视图
            .safeAreaInset(edge: .bottom) {
                VStack() {
                    VStack() {
                        HStack {
                            VStack(alignment: .leading) {
                                Text("连续包月 19.00").bold().padding(.bottom, 6)
                                Text("19元/月-自动续费可随时取消").font(.system(size: 10))
                            }
                            Spacer()
                            Text("立即开通")
                                .font(.system(size: 14))
                                .bold()
                                .padding(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20))
                                .foregroundColor(Color(hex: "#6F5021"))
                                .background(Color(hex: "#EACEA6"))
                                .cornerRadius(16)
                        }.foregroundColor(Color(hex: "#6F5021"))
                    }
                    .padding()
                    // 背景线性渐变,从左到右
                    .background(LinearGradient(gradient: Gradient(colors: [Color(hex: "#E7C899"), Color(hex: "#F9E9CF")]), startPoint: .leading, endPoint: .trailing))
                    .cornerRadius(12)
                    .padding(.bottom, 10)
                    VStack(alignment: .leading) {
                        HStack {
                            VStack(alignment: .leading) {
                                Text("购买年卡").padding(.bottom, 1)
                                HStack {
                                    Text("228.00").font(.headline)
                                    Text("(19元/月)").font(.subheadline)
                                }
                            }.frame(maxWidth: .infinity).font(.system(size: 14)) .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255)).padding().background(Color(red: 52/255, green: 58/255, blue: 78/255)).cornerRadius(12)
                            HStack {
                                Image(systemName: "gift").font(.system(size: 20))
                                VStack(alignment: .leading) {
                                    Text("赠送年卡给好友").padding(.bottom, 1)
                                    VStack(alignment: .leading) {
                                        Text("228.00").font(.headline)
                                    }
                                }
                            }.frame(maxWidth: .infinity).font(.system(size: 14)) .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255)).padding().background(Color(red: 52/255, green: 58/255, blue: 78/255)).cornerRadius(12)
                        }
                        HStack {
                            VStack(alignment: .leading) {
                                Text("购买季卡").padding(.bottom, 1)
                                HStack {
                                    Text("60.00").font(.headline)
                                    Text("(20元/月)").font(.subheadline)
                                }
                            }.frame(maxWidth: .infinity).font(.system(size: 14)) .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255)).padding().background(Color(red: 52/255, green: 58/255, blue: 78/255)).cornerRadius(12)
                            VStack(alignment: .leading) {
                                Text("购买月卡").padding(.bottom, 1)
                                VStack(alignment: .leading) {
                                    Text("30.00").font(.headline)
                                }
                            }.frame(maxWidth: .infinity).font(.system(size: 14)) .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255)).padding().background(Color(red: 52/255, green: 58/255, blue: 78/255)).cornerRadius(12)
                        }
                        Text("确认购买后,将向您的 iTunes 账户收款。购买连续包月项目,将自动续订,iTunes 账户会在到期前 24 小时内扣费。在此之前,您可以在系统[设置] -> [iTunes Store 与 App Store] -> [Apple ID] 里面进行退订。").font(.system(size: 10)).foregroundColor(Color.gray).padding(.top, 10)
                    }
                    
                }
                .padding()
                .background(
                    Color(red: 41/255, green: 50/255, blue: 75/255)
                        )
            }
            // 设置导航栏为行内模式
            .navigationBarTitleDisplayMode(.inline)
            // 自定义导航栏标题内容
            .toolbar {
                ToolbarItem(placement: .principal) {
                    VStack {
                        Text("会员卡").font(.headline).padding(.bottom, 2)
                        Text("已使用 517 天·累计节省 839.76 元").font(.system(size: 12))
                    }.foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
                }
            }
            // 自定义导航栏左右两边的按钮
            .navigationBarItems(
                leading: Button(action: {
                    // 点击按钮时的操作
                }, label: {
                    Image(systemName: "chevron.left")
                        .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
                }), trailing: Button(action: {
                    // 点击按钮时的操作
                }, label: {
                    Text("明细")
                        .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
                }))
        }
    }
}

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
    }
}

image.png

总结

我们学习了 VStack、HStack、ZStack 3个构建布局的重要工具,使用它们及其其他的视图和修饰符,完成了一个微信读书会员卡页面,这个页面包含了很多常见的布局方式和 swiftui 开发中常用的修饰符和控件,相信通过本文的学习,你已经掌握了不少技巧。大家还用 swiftui 开发出了哪些好看的页面呢,欢迎在评论区晒出你的作品。

这是 swiftui 开发之旅专栏的文章,是 swiftui 开发学习的经验总结及实用技巧分享,欢迎关注该专栏,会坚持输出。同时欢迎关注我的个人公众号 @JSHub:提供最新的开发信息速报,优质的技术干货推荐。

👍点赞:如果有收获和帮助,请点个赞支持一下!

🌟收藏:欢迎收藏文章,随时查看!

💬评论:欢迎评论交流学习,共同进步!