SwiftUI 轻松入门之登录界面

前言

SwiftUI出来也有段时间了,关于SwiftUI更多的信息请看这里,那么苹果为什么要推出SwiftUI呢?很多小伙伴会有疑问,有的公司可能还在用着OC进行的开发,还有些小伙伴可能连Swift都不是很了解,这怎么就又出来一个SwiftUI

回想一下我们再使用OC或者Swift进行UI开发的时候,假设我们要显示一个Label到屏幕中,我们要进行哪些操作呢?下面代码用Swift举例:

...
void viewDidload() {
    super.viewDidload()
    
    let label = UILabel()
    label.text = "你好,Swift"
    view.addSubview(label)
}
...

emmmm,这一切看起来都没有问题,先声明label,然后为label设置文字,最后在把他添加到View中。但是时代在进步呐,看看隔壁的Flutter,人家要显示一行文本到屏幕上面是怎么操作的?

...
  @override
  Widget build(BuildContext context) {
      return Text('Welcome to Flutter');
    }
...

去掉申明部分,别人一行代码就搞定了,明显比你优秀啊,而且人家的阅读性丝毫不比你弱,你怎么办~

这个时候苹果就在想了:“这个小伙子轻轻松松就可以把代码运行在多平台上,那开发者不是就更愿意用这个编写么?不行,老子要反击!!!”

所以SwiftUI就出来了,然后就实现了声明式或者函数式的方式来进行界面开发,由于是自家平台,要做到一份代码,多端通用自然也要提上日程,毕竟人是越来越懒了,能点头就搞定的,绝不开口说话。

我们看看SwiftUI如何实现显示文本:

...
var body: some View {
    Text("你好,Swift")
}
...

现在看起来和Flutter旗鼓相当了不是吗?SwiftUI充分利用了Swift的特性,可以省略分号,在某些情况下可以省略return,美滋滋~~

本文Demo地址

必看

本文默认你有Swift基础,如果没有请自行了解,至少熟悉基本语法,不然有些省略写法你看你会很晕

如果你之前连官方的Demo都没有看过,又没有网页、Flutter、小程序等开发经验,那么你暂时可以记住一句话,什么都是View,你所看到的都是View组成。

Xcode版本:11.4

macOS系统版本:10.15.3(你可以不是10.15以上的,但是如果要运行macOS版本,系统要求必须要10.15以上,最新版的Xcode也要10.15.2以上,所以升级吧!!!)

新建工程

image-20200327205509861.png

新建之后我们可以看到如下文件

目录

AppDeleagte

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

可以看到这里和我们之前的工程不一样了,之前那个Window的属性字段不见了,取而代之的是直接返回了UISceneConfiguration,在参数中我们可以看到有一个Default Configuration的字符串,这个字符串在我们的info.plist中可以查看到

info.plist

这个是iOS13新加入的,通过Scene管理App的生命周期,所以SceneDelegate接管了他

SceneDelegate

var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let contentView = ContentView()

        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

看到这个代码,大家应该都很熟悉了,这里和之前的创建方式基本类似了,这里我们看到,他的rootviewController是通过一个UIHostingController包装起来的,里面的rootView就是我们的ContentView,所以程序运行之后,我们看到的就是ContentView

ContentView

终于到今天的主角了~~~

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

这里的代码就是新鲜热乎的(如果你没看过SwiftUI的话)

这里我们看到ContentView是用Struct修饰的,不在是class了,然后又一个关键字some,这个是在之前的语法中没有的,也是在SwiftUI中加入的,你应该还记得上面提到的,你看到的都是View

public protocol View : _View {
    associatedtype Body : View
    var body: Self.Body { get }
}

可以看到,SwiftUI中的View是一个协议,但是View使用了associatedtype来修饰,他不能直接作为类型使用,他只能约束类型。所以就有关键字some

没它之前我要显示Label,要这样子写

var body: Text {
    Text("test")
}

要显示图片要这样子写:

var body: Image {
    Image("abc.png")
}

要根据不同的类型指定,这是一个很痛苦的事情,本来就是声明式UI,你还要我每个都指定一下,岂不是很麻烦。有了some只有,就美滋滋了,不管你显示什么,只要你遵循了View协议就成

var body: some View {
    Image("abc.png")
}

var body: some View {
    Text("label")
}

some怎么实现的????答案在这里

OK,到这里为止,我们看完了第一个结构体,但是下面还有一个ContentView_Previews,这个家伙又是来干什么的呢????

可以看到自动生成的代码后面携带了_Previews,字面上的意思就是预览!!!,嗯他就是用来预览的,毕竟隔壁的Flutter早就实现了,你作为后面出来小伙子,不能比前辈还少功能吧

如何开启预览???

previews

然后点击resume(在右上角),等待一会儿就可以了,至于预览显示的速度(看你电脑设备,我反正是放弃了)。

image-20200327212631982.png

友情提示(按下command然后点击文字,有惊喜哦)

属性

这个就比隔壁的Flutter要强大了,但是要看你为苹果充值了多少

开始干活

看完本期内容你将会了解

  • 如何跳转页面
  • 如何处理输入事件
  • @ViewBuilder
  • 如何桥接UIKit
  • 熟悉几个常用的View

1、新建两个文件

LoginAccountViewLoginPhoneView,新建的时候,记得要选择SwiftUI

2、修改ContentView

刚才我们建立了两个View,现在我们要通过一个列表显示两个选项,当我们点击的时候跳转过去

NavigationView 字面上上的意思,学过iOS开发的都知道,导航栏`View。

你可以把NavigationView看做是有导航栏的controller

我们要用列表展示两种登录方式然后你想列表,列表不就是List么~~,对就是这么简单

List展示一组列表,你可以把他看成是UITableView

有了List,我们需要一些Item,同时我们点击他的时候,需要他跳转到二级页面,跳转到二级页面也可以裂解为连接到下一级页面,所以这个关键字就是NavigationLink

NavigationLink拥有跳转到另外一个View的能力,之前提到过什么都是View组成,所以下一级页面也是一个View

他有三个参数:

  • 一个是destination:表示连接的View
  • 第二个是:isActive,用于表示是否已经激活下一个View了(或者说下一个View是不是已经显示了); 可忽略的参数
  • 最后一个是label:需要返回Viewclosure

最后我们在给这个导航栏设置一个标题

.navigationBarTitle(
    Text("登录Demo"), 
    displayMode: .large
)

SwiftUI中,默认的displayModelarge效果,具体啥样子,参考设置主页

large 和手机设置效果一样
inline,传统样式
automatic 支持large就使用large,否则就使用inline 

最后我们的ContentView代码是这样子的

struct ContentView: View {
    @State private var loginAccountIsActive: Bool = false
    @State private var loginPhoneIsActive: Bool = false
    var body: some View {
        NavigationView {
            List {
                NavigationLink(
                    destination: LoginAccountView(),
                    isActive: $loginAccountIsActive) {
                        Text("使用账户密码登录")
                }
                NavigationLink(
                    destination: LoginPhoneView(),
                    isActive: $loginPhoneIsActive) {
                        Text("使用手机号验证码登录")
                }
            }
                
            .navigationBarTitle(Text("登录Demo"), displayMode: .large)
        }
    }
}
loginDemo.gif

然后运行起来,你就可以看到一个有两个列表项的视图,点击某一项的时候,可以进行调整到对应的View

3、开始编写账号密码登录页面

先把下面的代码替换原来的实现

    @State var account: String = ""
    @State var password: String = ""
    var body: some View {
        VStack {
            HStack {
                Image(systemName: "person")
                TextField("请输入账号", text: $account, onCommit: {
                    
                })
            }
            Divider()
            HStack {
                Image(systemName: "lock")
                TextField("请输入密码", text: $password, onCommit: {
                    
                })
            }
            Divider()
            Spacer()
        }
        .padding(.top, 100)
        .padding(.leading)
        .padding(.trailing)
    }

首先来了一个之前没见过的修饰符@State,对于没见过的内容,一律command+点击,进入内部文档查看一下他的意思:

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {

    /// Initialize with the provided initial value.
    public init(wrappedValue value: Value)

    /// Initialize with the provided initial value.
    public init(initialValue value: Value)

    /// The current state value.
    public var wrappedValue: Value { get nonmutating set }

    /// Produces the binding referencing this state value
    public var projectedValue: Binding<Value> { get }
}

我们都知道,如果要在Struct中修改属性,就要添加mutating修饰,那你暂时可以理解为使用了@State修饰的属性,我们就可以控制的读写。

然后我们看到使用这个属性的时候是这样子的$account,这个在之前的Swift也是没有出现过的。其实这个就是配套@State使用的,如果对方需要的参数是Binding<T>,那么你就使用这个就好了。

@State$value是一种缩写的方式,他们本来长这个样子

@State private var a: Int = 0
priavte var a = State(initialValue: 0)

$a
a.binding

关于更多的这方面信息,请查看

接下来就是body部分了,这部分全是新内容!!!!

下面挨个解释一下啥意思

  • VStack

    垂直方向的Stack,上面的代码又是一种简写形式,他的功能就是在垂直方向,可以让你放入至多10个子View,未简写方式如下

            VStack(alignment: .leading, spacing: 10) {
                Text("xxxx")
            }
    

    默认的alignment.center

    默认的spacingnil

  • HStack

    VStack类似,只不过一个是垂直方向,一个是水平方向

  • ZStack

    ps: 这个虽然没有用到,但是顺带一起提了

    上面的VStackHStack都是沿着一个方向进行布局,如果我们想要进行叠加布局怎么办???ZStack就是干着活的。上面的三个Stack除了布局方式不一样,其他的都一样。

  • Image

    这个用来显示一张图片,内部不多,具体可以自行点击进去查看,需要说明的是,系统为我们提供了一堆内置的图片,使用Image(systemName: "xxx")进行调用,如果不知道名字怎么办!!!!

    福利地址 下载完成之后就可以查看了

  • TextField

    文本输入框,没啥好讲的,但是要吐槽一下,现在的TextField并不好用!!!!,能用的功能不多,要想做更多的事情,还是需要使用UITextField,这个也是后续会聊到的内容,如何桥接UITextFieldSwiftUI

  • Divider

    分割线

  • Spacer

    空白填充,如果不使用这个,那么我们的UI会是居中对齐的,如果我们想要填充对齐到某一个方向,就可以使用他

然后就是用到View的几个属性的

  • padding

    边距,如果你没有指定方向,默认就是四周,指定了一个之后,其他的就会失效,意思就是你指定了.top,如果此时你不指定左右下三个方向,那么他们是一点间距都没有的

OK到这里,我们就把上面的View的部分全部讲完了,你先运行也会看到这样子的UI

image-20200328100855116.png

接下来我们在花一点时间,把他完善一下

  • 密码的可见/隐藏
  • 登录按钮的实现

密码的可见和隐藏

在Swift中我们使用的是一个属性就可以控制了,很抱歉,在SwiftUI中并没有这样子的属性可以给到我们,所以他提供了另外一个输入框,专门给我们使用

  • SecureField

    这个View输入的内容是不可见的(也就是一堆小圆点)

一般来说,密码是否可见,我们会有一个按钮去显示控制

所以我们需要加入一个新的ViewButton

SwiftUI为我们提供了好几种Button,目前我们只需要使用一种就好了,有兴趣的可以去官网自行查看。

在第二个HStack中我们新增一个Button,并新增一个属性,用来控制是否可以显示按钮

var showPwd = false

...HStack
Button(action: {
    self.showPwd.toggle()
}) {
    Image(systemName: self.showPwd ?
"eye" : "eye.slash")
}

然后就给你报错了,这是因为你没给showPwd这个属性添加 @State,加上之后就没事了。

现在按钮是可以点击了,图片也在切换了,但是密码还是公开的,接下来我们就把这部分实现

把TextField的代码修改为如下代码

Image(systemName: "lock")
if showPwd {
    TextField("请输入密码", text: $password, onCommit: {
        
    })
} else {
    SecureField("请输入密码", text: $password, onCommit: {
        
    })
}

再次运行之后,就可以愉快的切换了

登录按钮的实现

DeviderSpacer之间插入一个Button,同时添加一个属性isCanLogin

var isCanLogin: Bool {
    account.count > 0 &&
    password.count > 0
}


Button(action: {
    print("login action")
}) {
    Text("Login")
        .foregroundColor(.white)
}
.frame(width: 100, height: 45, alignment: .center)
.background(isCanLogin ? Color.blue: Color.gray)
.cornerRadius(10)
.disabled(!isCanLogin)

这里我们使用了几个View的属性

  • frame

    设置大小和对齐方式

  • background

    背景,这里使用的是协议进行的约束,也就是你只要遵从了该协议就行,Color就遵循了

  • cornerRadius

    圆角

  • disabled

    是否是非激活状态

效果图
loginAccount.gif

4、编写手机号登录界面

再开始之前,指出我们上面的登录界面的一些体验不友好的地方

  • 键盘无法自动消失
  • 没有限制TextField的最大输入长度

接下来的代码中,我们就要优化这个问题

桥接UITextFieldSwiftUI

新建一个文件PQTextField继承协议UIViewRepresentable,这个协议就是用来桥接的,其他的暂时不管。

你只要记得三个重要的方法

  • makeUIView

    创建桥接的UIKit

  • updateUIView

    更新他

  • makeCoordinator

    UIKit代理的实现者

然后我们参考上面的TextView,我们要做一个体验和TextField基本一致的View出来

struct PQTextField: UIViewRepresentable {
    typealias PQTextFieldClosure = (UITextField) -> Void
    /// placeholder
    var placeholder: String? = nil
    /// max can input length
    var maxLength: Int? = nil
    /// default text
    var text: String? = nil
    /// onEditing
    var onEditing: PQTextFieldClosure?
    /// onCommit
    var onCommit: PQTextFieldClosure?
    /// 配置时使用
    var onConfig: PQTextFieldClosure?
    
    func makeUIView(context: Context) -> UITextField {
        
    }
    
    func updateUIView(_ tf: UITextField, context: Context) {
        
    }
    
    func makeCoordinator() -> Coordinator {
        
    }
}

然后我们依次把空白的地方补全

首先是makeUIView,这里需要我们返回一个UIKit的视图

    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        return textField
    }

然后分析我们要实现的功能,监听UITextField输入情况,这里要设置他的代理;设置的他的初始值,比如placeholder

创建代理类
  class Coordinator: NSObject, UITextFieldDelegate {
        let textField: PQTextField
        var onEditing: PQTextFieldClosure?
        var onCommit: PQTextFieldClosure?
        
        init(_ tf: PQTextField, onEditing: PQTextFieldClosure?, onCommit: PQTextFieldClosure?) {
            self.textField = tf
            self.onEditing = onEditing
            self.onCommit = onCommit
        }
        
        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
            onEditing?(textField)
            var length = range.location + 1
            if string == "", textField.text?.count ?? 0 == range.location + range.length { // 表示是删除
                length -= 1
            }
            if length >= self.textField.maxLength ?? -1 {
                onCommit?(textField)
            }
            
            if let maxLength = self.textField.maxLength, string != "" {
                let value = (textField.text?.count ?? 0) < maxLength
                return value
            }
            
            return true
        }
        
        func textFieldDidEndEditing(_ textField: UITextField) {
            onCommit?(textField)
            onCommit = nil
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            onCommit?(textField)
            onCommit = nil
            return true
        }
        
        @objc
        func textChange(textField: UITextField) {
            onEditing?(textField)
        }
    }

代理类里面的代码就是Swift的部分,和SwiftUI半毛钱关系都没有,具体做的事情就是监听代理,然后通过closure回调出去

实现makeCoordinator方法
    func makeCoordinator() -> Coordinator {
        Coordinator(self, onEditing: onEditing, onCommit: onCommit)
    }
然后在makeUIView中补全代码
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.delegate = context.coordinator
        textField.placeholder = placeholder
        textField.addTarget(context.coordinator, action: #selector(context.coordinator.textChange(textField:)), for: .editingChanged)
        textField.text = text
        onConfig?(textField)
        return textField
    }
实现updateUIView
    func updateUIView(_ tf: UITextField, context: Context) {
        tf.placeholder = placeholder
        tf.text = text
    }

最后完整的代码如下


struct PQTextField: UIViewRepresentable {
    typealias PQTextFieldClosure = (UITextField) -> Void
    /// placeholder
    var placeholder: String? = nil
    /// max can input length
    var maxLength: Int? = nil
    /// default text
    var text: String? = nil
    /// onEditing
    var onEditing: PQTextFieldClosure?
    /// onCommit
    var onCommit: PQTextFieldClosure?
    /// 配置时使用
    var onConfig: PQTextFieldClosure?
    
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.delegate = context.coordinator
        textField.placeholder = placeholder
        textField.addTarget(context.coordinator, action: #selector(context.coordinator.textChange(textField:)), for: .editingChanged)
        textField.text = text
        onConfig?(textField)
        return textField
    }
    
    func updateUIView(_ tf: UITextField, context: Context) {
        tf.placeholder = placeholder
        tf.text = text
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self, onEditing: onEditing, onCommit: onCommit)
    }
    
    
    class Coordinator: NSObject, UITextFieldDelegate {
        let textField: PQTextField
        var onEditing: PQTextFieldClosure?
        var onCommit: PQTextFieldClosure?
        
        init(_ tf: PQTextField, onEditing: PQTextFieldClosure?, onCommit: PQTextFieldClosure?) {
            self.textField = tf
            self.onEditing = onEditing
            self.onCommit = onCommit
        }
        
        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
            onEditing?(textField)
            var length = range.location + 1
            if string == "", textField.text?.count ?? 0 == range.location + range.length { // 表示是删除
                length -= 1
            }
            if length >= self.textField.maxLength ?? -1 {
                onCommit?(textField)
            }
            
            if let maxLength = self.textField.maxLength, string != "" {
                let value = (textField.text?.count ?? 0) < maxLength
                return value
            }
            
            return true
        }
        
        func textFieldDidEndEditing(_ textField: UITextField) {
            onCommit?(textField)
            onCommit = nil
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            onCommit?(textField)
            onCommit = nil
            return true
        }
        
        @objc
        func textChange(textField: UITextField) {
            onEditing?(textField)
        }
    }
}

有了上面的基础,View搭建这块我们就手到擒来了


struct LoginPhoneView: View {
     @State private var phoneNumber: String = ""
     @State private var code: String = ""
     @State private var phoneNumIsEdit = false
     @State private var codeIsEdit = false
     @State private var timer: Timer?
     @State private var countDown = 60
     var isPhoneNum: Bool {
         if accountIsEdit {
             return phoneNumber.count == 11
         }
         return true
     }
     var isCode: Bool {
         if codeIsEdit {
             return code.count == 4
         }
         return true
     }
     var isCanLogin: Bool {
         isPhoneNum && isCode
     }
     var body: some View {
         VStack {
             VStack {
                 HStack {
                     Image(systemName: "phone.down.circle")
                         .rotationEffect(Angle(degrees: 90))
                     
                     PQTextField(placeholder: "请输入号码", maxLength: 11,text: phoneNumber, onEditing: { tf in
                     }, onCommit:  { tf in
                     })
                         .frame(height: 40)
                 }
                 if !isPhoneNum {
                     Text("手机号码应该是11位数字")
                         .font(.caption)
                         .foregroundColor(.red)
                 }
                 Divider()
             }
             
             VStack {
                 HStack {
                     PQTextField(placeholder: "请输入验证码", maxLength: 4, text: code, onEditing: { tf in
                     }, onCommit: { tf in
                     })
                         .frame(height: 40)
                     Button(action: {
                         // get code
                     }, label: {
                         Text((countDown == 60) ? "获取验证码" : "请\(countDown)s之后重试")
                     }).disabled(countDown != 60 || phoneNumber.count != 11)
                 }
                 if !isCode {
                     Text("请输入正确的验证码(4位数字)")
                         .font(.caption)
                         .foregroundColor(.red)
                         .frame(alignment: .top)
                 }
                 
                 Divider()
             }
             
             Button(action: {
                 print("login action", self.phoneNumber, self.code)
             }) {
                 Text("Login")
                     .foregroundColor(.white)
             }.frame(width: 100, height: 45, alignment: .center)
                 .background(isCanLogin ? Color.blue: Color.gray)
                 .cornerRadius(10)
                 .disabled(!isCanLogin)
             
             Spacer()
         }
         .onAppear {
             self.createTimer()
         }
         .onDisappear {
             self.invalidate()
         }
         .padding()
         
     }
     
     private func createTimer() {
        
     }
     
     private func invalidate() {
        
     }
}

首先我们创建了几个属性

  • phoneNumber 保存手机使用
  • code 验证码
  • phoneNumIsEdit 是否开始输入手机号了
  • codeIsEdit 是否开始输入验证码了
  • timer 倒计时的时候使用
  • countDown 倒计时的时间
  • isPhoneNum 判断是不是手机号,这里只做了非常简单的判断
  • isCode 判断是不是验证码,这里也是非常简单的判断
  • isCanLogin 是否可以登录了(控制按钮是否可以点击)

接下来的视图部分和之前大体相同,这部分的代码带过

最后我们看到我们又使用了两个新的方法

  • onAppear

    这个会在视图加载的时候调用

  • onDisappear

    这个会在视图消失的时候调用

那么在这里做啥子呢?,没错,就是用来场景定时器的

我们去实现两个定时器方法

创建定时器

    private func createTimer() {
        if timer == nil {
            timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (t) in
                if self.countDown < 0 {
                    self.countDown = 0
                    t.invalidate()
                }
                self.countDown -= 1
            })
            // 先不触发定时器
            timer?.fireDate = .distantFuture
        }
    }

创建定时器,这里一定要注意的是,一定要做好判断,不能重复创建定时器,否则会有多少个定时器同时在跑,尤其是当前界面进入下级页面的时候

销毁定时器

    private func invalidate() {
        timer?.invalidate()
    }

为什么创建的时候做了判断,但是销毁的时候却没有处理呢???

如果你足够细心,那你一定看到了countDown是用@State修饰的

最后我们补全在PQTextFieldClosure的代码之后,完整的代码如下

struct LoginPhoneView: View {
     @State private var phoneNumber: String = ""
     @State private var code: String = ""
     @State private var phoneNumIsEdit = false
     @State private var codeIsEdit = false
     @State private var timer: Timer?
     @State private var countDown = 60
     var isPhoneNum: Bool {
         if phoneNumIsEdit {
             return phoneNumber.count == 11
         }
         return true
     }
     var isCode: Bool {
         if codeIsEdit {
             return code.count == 4
         }
         return true
     }
     var isCanLogin: Bool {
         isPhoneNum && isCode
     }
     var body: some View {
         VStack {
             VStack {
                 HStack {
                     Image(systemName: "phone.down.circle")
                         .rotationEffect(Angle(degrees: 90))
                     
                     PQTextField(placeholder: "请输入号码", maxLength: 11,text: phoneNumber, onEditing: { tf in
                        self.phoneNumIsEdit = true
                        self.phoneNumber = tf.text ?? ""
                     }, onCommit:  { tf in
                        self.phoneNumIsEdit = false
                        self.phoneNumber = tf.text ?? ""
                     })
                    .frame(height: 40)
                 }
                 if !isPhoneNum {
                     Text("手机号码应该是11位数字")
                         .font(.caption)
                         .foregroundColor(.red)
                 }
                 Divider()
             }
             
             VStack {
                 HStack {
                     PQTextField(placeholder: "请输入验证码", maxLength: 4, text: code, onEditing: { tf in
                        self.codeIsEdit = true
                        self.code = tf.text ?? ""
                     }, onCommit: { tf in
                        self.codeIsEdit = false
                        self.code = tf.text ?? ""
                     })
                         .frame(height: 40)
                     Button(action: {
                         // get code
                     }, label: {
                         Text((countDown == 60) ? "获取验证码" : "请\(countDown)s之后重试")
                     }).disabled(countDown != 60 || phoneNumber.count != 11)
                 }
                 if !isCode {
                     Text("请输入正确的验证码(4位数字)")
                         .font(.caption)
                         .foregroundColor(.red)
                         .frame(alignment: .top)
                 }
                 
                 Divider()
             }
             
             Button(action: {
                 print("login action", self.phoneNumber, self.code)
             }) {
                 Text("Login")
                     .foregroundColor(.white)
             }.frame(width: 100, height: 45, alignment: .center)
                 .background(isCanLogin ? Color.blue: Color.gray)
                 .cornerRadius(10)
                 .disabled(!isCanLogin)
             
             Spacer()
         }
         .onAppear {
             self.createTimer()
         }
         .onDisappear {
             self.invalidate()
         }
         .padding()
         
     }
     
     private func createTimer() {
        if timer == nil {
            timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (t) in
                if self.countDown < 0 {
                    self.countDown = 0
                    t.invalidate()
                }
                self.countDown -= 1
            })
            // 先不触发定时器
            timer?.fireDate = .distantFuture
        }
     }
     
     private func invalidate() {
        timer?.invalidate()
     }
}

最终我们的两个小Demo就完成了。

第二个Demo基于第一个,如果你第二个没懂,你看你需要再去看看第一个Demo

loginPhone.gif
实现点击空白处隐藏键盘

新建文件DismissKeyboard.swift

首先分析一下功能,点击空白处,空白处的ViewSpacerSpacer又遵循View协议,那我们可以为View扩展一个隐藏键盘的方法

import SwiftUI

extension View {
    func endEditing() {
        UIApplication.shared.sendAction(
            #selector(UIResponder.resignFirstResponder),
            to: nil,
            from: nil,
            for: nil
        )
    }
}

这里不建议使用keywindow的方法去做了

然后为了方便其他的View使用,自定义了一个struct遵从ViewModifier协议

struct DismissKeyboard: ViewModifier {
    func body(content: Content) -> some View {
        content.onTapGesture {
            content.endEditing()
        }
    }
}

如何使用呢???

Text("xxxx")
.modifier(DismissKeyboard())

其实ViewModifier的妙用有很多,这里只是举了一个例子,比如我们要为某一个视图设置独特的样式,我们就可以新建一个文件,然后编写样式,之后只要需要用到这个样式的,就可以用类似上面的调用方法。

题外话: 那除了使用ViewModifier之外呢,我们还可以使用@ViewBuilder去做

struct DismissKeyboardBuilder<Content: View>: View {
    let content: Content
    init(@ViewBuilder _ content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        content.onTapGesture {
            self.content.endEditing()
        }
    }
}

他们两个的区别,我个人认为一个像继承,一个像协议。扯远了~~~

最后我们新建一个自己的Spacer

public struct DismissKeyboardSpacer: View {
    public private(set) var minLength: CGFloat? = nil
    
    public init(minLength: CGFloat? = nil) {
        self.minLength = minLength
    }
    
    public var body: some View {
        ZStack {
            Color.black.opacity(0.001)
                .modifier(DismissKeyboard())
            Spacer(minLength: minLength)
        }
        .frame(height: minLength)
    }
    
}

然后把LoginPhoneView里面的Spacer替换成为我们自己创建的DismissKeyboardSpacer,再去运行一下看下效果

loginPhone.gif

到这里我们的入门教程之登陆界面就完了!!!

回顾一下我们学到了哪些东西!!!

首先视图方面

HStack、VStack、ZStack、List、Button、Text、TextFiled、Divider、Spacer、NavigationView、NavigationLink

然后方法方面

frame、padding、rotationEffect、font、foregroundColor、background、disabled、cornerRadius、onAppear、onDisappear

还了解了定时器的创建,UIKit的桥接、@ViewBuilder、ViewModifier、@State、Binding

希望对你有所收获

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,907评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,546评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,705评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,624评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,940评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,371评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,672评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,396评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,069评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,350评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,876评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,243评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,847评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,004评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,755评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,378评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,266评论 2 259

推荐阅读更多精彩内容