第三十章 接下来我们写首页的功能,首先是我们的`托盘绑定箱号`。

0 阅读5分钟

托盘绑定箱号

创建托盘绑定箱号界面

新建 ViewModel

class PalletBindBoxNumberPageViewModel: BaseViewModel {   
}

新建 Page

struct PalletBindBoxNumberPage: View {
    @StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            EmptyView()
        }
    }
}

新增首页跳转 PalletBindBoxNumberPage

NavigationLink

对于导航的跳转,我们需要用到NavigationLink.

struct HomePage: View {
    ...
    var body: some View {
        ... {
            ... {
                ActionCardView(
                    title: "生产执行",
                    actions: [
                    	/// ActionItem
                        ...
                ])
                 ...
            }
        } 
        ...
}
struct ActionItem: Hashable {
    ...
}

ActionItem不是一个View,因此不能够使用NavigationLink

Function方法体内部执行 NavigationLink跳转。

HomePageViewModel 新增一个控制 NavigationLink 激活的变量

class HomePageViewModel: BaseViewModel {
    ...
    /// 是否允许跳转界面
    @Published var isAllowPushPage:Bool = false
    ...
}

HomePage 新增一个不可见的 NavigationLink

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            VStack {
                ...
                VStack(spacing:0) {
                    NavigationLink(isActive: $viewModel.isAllowPushPage) {
                        
                    } label: {
                        EmptyView()
                    }
                    Spacer()
                }
            }
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        ...
    }
}

获取点击首页按钮 ActionItem

HomePageViewModel 新增记录选中 ActionItem的变量。
class HomePageViewModel: BaseViewModel {
    ...
    /// 当前点击按钮的 `ActionItem`
    @Published var currentClickActionItem:ActionItem?
    ...
}
ActionCardView
struct ActionCardView: View {
    ...
    @Binding var currentClickActionItem:ActionItem?
    var body: some View {
        VStack {
            ...
            HStack(alignment:.top) {
                HStack {
                    ActionView(actionItems: actions(index: .left),
                               currentClickActionItem: $currentClickActionItem)
                    ...
                }
                ...
                HStack {
                    ActionView(actionItems: actions(index: .center),
                               currentClickActionItem: $currentClickActionItem)
                }
                ...
                HStack {
                    ...
                    ActionView(actionItems: actions(index: .right),
                               currentClickActionItem: $currentClickActionItem)
                }
                ...
            }
            ...
        }
        ...
    }
		...
}
ActionView
struct ActionView: View {
    ...
    @Binding var currentClickActionItem:ActionItem?
    var body: some View {
        VStack {
            ForEach(actionItems, id: \.self) { item in
                ...
                    .onTapGesture {
                        currentClickActionItem = item
                    }
            }
        }
    }
}

HomePage
struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            VStack {
                ActionCardView(
                    title: "生产执行",
                    actions: [
                        ....
                    ], currentClickActionItem: $viewModel.currentClickActionItem)
                    ...
                }
            }
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        .onAppear {
            ...
        }
    }
}

监听 currentClickActionItem 值的改变,执行跳转。

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        ...
        .onChange(of: viewModel.currentClickActionItem) { newValue in
            viewModel.isAllowPushPage = true
        }
    }
}

根据 ActionItem 返回对应的 Page

extension HomePageViewModel {
    var actionPage: some View {
        return currentClickActionItem.map { item in
            Group {
                if item.title == "托盘绑定箱号" {
                    PalletBindBoxNumberPage()
                } else {
                    EmptyView()
                }
            }
        }
    }
}

HomePage

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            VStack {
                ...
                VStack(spacing:0) {
                    NavigationLink(isActive: $viewModel.isAllowPushPage,
                                   destination: {viewModel.actionPage}) {
                        EmptyView()
                    }
                    ...
                }
            }
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        ...
    }
}

37102565-781C-4B7A-A024-6E871B4AF579-12013-00001DCBE6C95C10

修复返回按钮样式不对

image-20211217083137276

隐藏返回按钮文本

let backButtonAppearance = UIBarButtonItemAppearance()
backButtonAppearance.normal.titleTextAttributes = [
    .font : UIFont.systemFont(ofSize: 0),
]
appearance.backButtonAppearance = backButtonAppearance

修改 SwiftUI 返回按钮的颜色

NavigationView {
	...
}
.accentColor(.black)

需要注意的是官方说accentColor已经要废弃了,Use the asset catalog's accent color or View.tint(_:) instead."

但是替换为 tint不起作用。

没有隐藏底部的 Tab

image-20211217104349972

目前在 SwiftUI中暂时没有任何方便的方法可以在 NavigationView 进行 Push 跳转隐藏底部的 Tabbar。我们只能在需要隐藏的界面的 onAppearonDisappear去隐藏。

/// ❌ 这样设置是不起作用的
UITabBar.appearance().isHidden = true

我们在运行时候,看一下布局。

image-20211217110816676

我们按照结构找出 UITabbar

if let appBar = App.keyWindow?.rootViewController
    .flatMap({$0.view})
    .flatMap({$0.subviews.first})
    .flatMap({$0.subviews.first})
    .map({$0.subviews})
    .map({$0.compactMap({$0 as? UITabBar})})
    .flatMap({$0.first}) {
    print(appBar)
}

App 获取当前 Tabbar 的方法

struct App {
    ...
    
    static var tabBar:UITabBar? {
        return keyWindow?.rootViewController
            .flatMap({$0.view})
            .flatMap({$0.subviews.first})
            .flatMap({$0.subviews.first})
            .map({$0.subviews})
            .map({$0.compactMap({$0 as? UITabBar})})
            .flatMap({$0.first})
    }
}

隐藏和显示当前 UITabbar

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            ...
        }
        .onAppear {
            App.tabBar?.isHidden = false
        }
        .onDisappear {
            App.tabBar?.isHidden = true
        }
    }
}

image-20211217114659348

隐藏UITabar之后多出了很多空白的区域,我们设置忽略安全距离。

/// 页面的基础试图
struct PageContentView<Content:View,
                        Leading:View,
                        Trailing:View,
                        ViewModel:BaseViewModel>: View {
    ...
    var body: some View {
        navigationBar {
            ZStack {
                content
                    .background {
                        Color(uiColor: appColor.c_efefef)
                            .ignoresSafeArea()
                    }
            }
            ...
        }
    }
    ...
}

封装 Detail 页面

为了让后面的界面一样拥有 隐藏UITabBar我们需要进行封装成DetailView,方便后续的使用。

新建一个 DetailPageViewModify

struct DetailPageViewModify: ViewModifier {
    func body(content: Content) -> some View {
        content
            .onAppear {
                App.tabBar?.isHidden = true
            }
            .onDisappear {
                App.tabBar?.isHidden = false
            }
    }
}

extension View {
    func makeToDetailPage() -> some View {
        self.modifier(DetailPageViewModify())
    }
}

将 PalletBindBoxNumberPage 页面使用 DetailPageViewModify

struct PalletBindBoxNumberPage: View {
    @StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            EmptyView()
        }
        .makeToDetailPage()
    }
}

修复第二次相同页面无法 Push 问题

E30B5E5D-03E3-4AB4-9B9E-F1D57042B2AC-6170-0000086B21AA549E

从上面的掩饰发现,第一次是可以正常的进入,点击返回,第二次无法Push进入。只有点击其他页面返回之后,才能正常的返回。

打印点击 Push 对应的 ActionItem

newValue = Optional(Win_.ActionItem(icon: "托盘绑定箱号", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "托盘绑定箱号"))
newValue = Optional(Win_.ActionItem(icon: "灭菌整板(有箱号)", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "灭菌整板(有箱号)"))
newValue = Optional(Win_.ActionItem(icon: "托盘绑定箱号", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "托盘绑定箱号"))

发现在整个过程中,点击第二次是没有走 onChange,是因为检测到值相同,是因为ActionItem实现了Hashable协议。

在 HomePage 的 onAppear 方法重置 currentClickActionItem

struct HomePage: View {
    ...
    var body: some View {
        ...
        .onAppear {
            ...
            viewModel.currentClickActionItem = nil
        }
        ...
    }
}

2DD48351-20FB-4E98-A12F-93D5DE903641-6170-00000B685153E499

经过重置,第二次Push无法跳转问题解决了。

封装扫描输入组件

image-20211217144859246

接下来我们封装上面的组件,大致的界面构造如下。

image-20211217145319468

新建一个 ScanTextView

struct ScanTextView: View {
    @StateObject private var appColor = AppColor.share
    /// 前面的标题
    private let title:String
    /// 输入框的提示文本
    private let prompt:String
    /// 输入框输入的内容
    @Binding private var text:String
    init(title:String, prompt:String, text:Binding<String>) {
        self.title = title
        self.prompt = prompt
        self._text = text
    }
    var body: some View {
        HStack {
            HStack {
                Text("*")
                    .foregroundColor(Color(uiColor: appColor.c_e68181))
                Text(title)
              Spacer()
            }
            TextField(prompt, text: $text)
                .frame(height:33)
            Image("scan_icon", bundle: .main)
        }
        .font(.system(size: 14))
        .padding()
    }
}

image-20211217152532232

添加栈版号和箱号

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack {
                VStack {
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                }
                .background(.white)
                Spacer()
            }
        }
        ...
    }
}
class PalletBindBoxNumberPageViewModel: BaseViewModel {
    /// 输入的栈版号
    @Published var palletNumber:String = ""
    /// 箱号
    @Published var boxNumber:String = ""
}

image-20211217170131720

固定 ScanTextView 的 Title 的宽度

image-20211217170240820

提示语是没有对齐的,因为是自动布局,很难会让自动的对齐,我们需要设置左侧标题固定长度。

struct ScanTextView: View {
    ...
    /// 默认为 100
    private let titleWidth:CGFloat
    init(title:String, prompt:String, text:Binding<String>, titleWidth:CGFloat = 100) {
        ...
        self.titleWidth = titleWidth
    }
    var body: some View {
        HStack {
            HStack {
                ...
            }
            .frame(width: titleWidth)
            ...
        }
        ...
    }
}

栈版号和箱号中间添加分割线

struct PalletBindBoxNumberPage: View {
    @StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                VStack(spacing:0) {
                    ...
                    Divider()
                        .padding(.leading)
                   ...
                }
                ...
            }
        }
        ...
    }
}

箱号详情组件

image-20211217171227535

分析布局如下。

image-20211217173615566

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                HStack {
                    Text("物料编号:")
                    Text("A")
                }
                HStack {
                    Text("物料批号:")
                    Text("120211217A")
                }
            }
            VStack {
                HStack {
                    Text("工单号:")
                    Text("WO-201425")
                }
                HStack {
                    Text("箱号:")
                    Text("BOX-01")
                }
            }
        }
        .padding(15)
        .frame(maxWidth: .infinity)
        .background(.white)
        .cornerRadius(10)
    }
}

image-20211217175610405

制作标题信息组件

我们需要标题和信息上对齐,类似下面的排版方案。

image-20211220090538999

struct TitleValueView: View {
    @StateObject private var appColor = AppColor.share
    private let title:String
    private let value:String
    init(title:String, value:String) {
        self.title = title
        self.value = value
    }
    var body: some View {
        HStack(alignment:.firstTextBaseline) {
            Text(title)
                .foregroundColor(Color(uiColor: appColor.c_999999))
            Text(value)
                .foregroundColor(Color(uiColor: appColor.c_333333))
        }
        .font(.system(size: 14))
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

image-20211220091532902

将箱号详情标题和描述替换为 TitleValueView 组件

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                TitleValueView(title: "物料编号:",
                               value: "A")
                TitleValueView(title: "物料批号:",
                               value: "120211217A")
            }
            VStack {
                TitleValueView(title: "工单号:",
                               value: "WO-201425")
                TitleValueView(title: "箱号:",
                               value: "BOX-01")
            }
        }
        ...
    }
}

image-20211220092135131

调整上下组件的间距

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                ...
                Spacer()
                    .frame(height: 7.5)
                ...
            }
            VStack {
                ...
                Spacer()
                    .frame(height: 7.5)
                ...
            }
        }
        ...
    }
}

image-20211220092422575

可手动控制 Title 的宽度

我们给TitleValueView新增一个可以手动控制Title宽度的参数,如果不为0则手动控制高度。

struct TitleValueView: View {
    ...
    private let titleWidth:CGFloat
    init(title:String, value:String, titleWidth:CGFloat = 0) {
        ...
        self.titleWidth = titleWidth
    }
    var body: some View {
        HStack(alignment:.firstTextBaseline) {
            if titleWidth == 0 {
                titleText
            } else {
                titleText
                    .frame(width: titleWidth, alignment: .leading)
            }
            ...
        }
        ...
    }
    
    private var titleText: some View {
        Text(title)
            .foregroundColor(Color(uiColor: appColor.c_999999))
    }
}

我们将工单号和箱号宽度保持一致

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                ...
            }
            VStack {
                TitleValueView(title: "工单号:",
                               value: "WO-201425",
                               titleWidth: 50)
                ...
                TitleValueView(title: "箱号:",
                               value: "BOX-01",
                               titleWidth: 50)
            }
        }
        ...
    }
}

image-20211221105603227

固定 ScanTextView的高度

image-20211221110011577

经过自动布局之后的ScanTextView的高度达到了65的高度,超出了设计图50的高度,主要是输入框固定了高度,我们将去掉Padding,给ScanTextView设置固定高度为50

struct ScanTextView: View {
    ...
    var body: some View {
        HStack {
            ...
        }
        ...
        .frame(height:50)
    }
}

image-20211221110351933

只增加左右间距

高度50设置完毕,但是左右靠边,我们只设置边距左右为10

struct ScanTextView: View {
    ...
    var body: some View {
        HStack {
            ...
        }
        ...
        .padding(.leading, 10)
        .padding(.trailing, 10)
      	/// 或者
      	/// .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
    }
}

image-20211221111955278

获取箱号列表

新增 @Published 参数箱号列表 用于更新列表

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 箱子列表
    @Published var boxDetailModels:[BoxDetailModel] = []
}

新增根据栈版号获取箱号列表方法

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 请求获取箱子列表
    func requestBoxDetailList() async {
        let api = PalletQueryApi(palletCode: palletNumber)
        let model:BaseModel<[BoxDetailModel]> = await request(api: api)
        guard model._isSuccess else { return }
        boxDetailModels = model.data ?? []
    }
}

当输入栈版号结束之后请求箱号列表

怎么才能监听到输入完毕呢?我们可以使用onSubmit这个扩展获取。

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                VStack(spacing:0) {
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                        .onSubmit {
                            Task {
                                await viewModel.requestBoxDetailList()
                            }
                        }
                    ...
                }
                ...
            }
        }
        ...
    }
}

添加或者删除箱号

此时我们的栈板上是没有数据的,需要我们输入箱号进行新增和删除操作。

image-20211221142928191

上图的逻辑都封装在接口里面,所以我们只需要关心输入箱号之后,调用接口即可。

添加新增或者删除箱号逻辑方法

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 添加或者移除箱号
    func addOrRemoveBox() async {
        let api = BoxAddApi(palletCode: palletNumber, boxCode: boxNumber)
        let model:BaseModel<String> = await request(api: api)
        guard model._isSuccess else {return}
        /// 重新获取列表 刷新界面
        await requestBoxDetailList()
    }
}

给箱号输入框添加onSubmit方法

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                VStack(spacing:0) {
                   ...
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                        .onSubmit {
                            Task {
                                await viewModel.addOrRemoveBox()
                            }
                        }
                }
                ...
            }
        }
        ...
    }
}

给请求添加HUD

此时添加箱号成功了

{"code":200,"data":"箱号绑定栈板成功!!!","message":"success","objectType":null,"success":true}

在日志也看不出来乱码显示,我们希望提示给用户。

给获取箱子列表添加HUD

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 请求获取箱子列表
    func requestBoxDetailList() async {
        ...
        let model:BaseModel<[BoxDetailModel]> = await request(api: api, showHUD: true)
        ...
    }
    ...
}

06AD995C-44B3-4F97-85FA-EB546715CFE9-32874-0000111BEBD404CE

此时我们已经获取到列表了,但是HUD没有消失,主要是逻辑中没有调用隐藏HUD。

给BaseViewModel新增Hidden HUD方法

@MainActor
class BaseViewModel: ObservableObject {
    ...
    func hiddenHUD() {
        self.isLoadingHUD = false
    }
    ...
}

给查询箱号和新增和删除箱号添加HUD和移除HUD

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    
    /// 请求获取箱子列表
    func requestBoxDetailList() async {
        ...
        hiddenHUD()
        ...
    }
    
    /// 添加或者移除箱号
    func addOrRemoveBox() async {
       ...
        let model:BaseModel<String> = await request(api: api, showHUD: true)
        ...
        hiddenHUD()
        ...
    }
}

添加或者删除成功提示

上面的代码我们还是无法成功显示提示语,到底是添加成功还是删除成功。当我们请求完毕,展示获取的Data字符串。

8E0091D0-4423-4821-A20E-60F1D087D5AF-32874-000011FEBD5094D8

但是展示和隐藏十分的快,在显示没有结束之前,被后面获取箱子列表接口在请求完毕之后隐藏了。

image-20211221164303927

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    
    /// 添加或者移除箱号
    func addOrRemoveBox() async {
        ...
        if let message = model.data {
            showHUDMessage(message: message)
        }
    }
}

A25D7D14-4057-4781-80AC-049B8CC6DFD8-32874-0000127C741D4A5D

修复HUD开始显示之前内容的问题

HUD展示逻辑

image-20211221170320889

HUD Message展示逻辑

image-20211221194016155

我们看到在展示文本延时两秒之后,文本没有清空,导致下次请求进行Loading时候因为文本不为空,展示不是一个Loading HUD而是上一个提示的文本。

清空上一个展示的文本

修复这个问题,大概有两种方案

方案1 在延时两秒隐藏时候 清空文本

@MainActor
class BaseViewModel: ObservableObject {
    ...
    
    /// 展示 HUD 文本
    /// - Parameter message: 提示的信息
    func showHUDMessage(message:String) {
        ...
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            ...
            self.hudMessage = ""
        }
    }
    
    ...
}

方案2 在展示HUD的时候 清空之前的文本

@MainActor
class BaseViewModel: ObservableObject {
    ...
    func request<T:Codable, API:APIConfig>(api:API, showHUD:Bool = false) async -> BaseModel<T> {
        if (showHUD) {
            hudMessage = ""
            ...
        }
        ...
    }
}

展示HUD Message的文本内容只是一个临时的展示内容,应该在展示完毕重置,所以第一种方案比较好。

6FB09227-0EC0-4CE3-9B88-C3E71BE1A47A-32874-00001AAF68A2AB4A