SwiftUI:适用于 macOS 和 iOS 的自定义选项卡视图

SwiftUI: Custom Tab View for macOS & iOS

有没有一种简单的方法可以使用 SwiftUI 获得更可自定义的标签栏视图?我主要是从 macOS 的角度来问的(虽然在任何系统上都可以工作的是最理想的),因为标准的 macOS 实现有各种问题:

当前代码:

import SwiftUI

struct SimpleTabView: View {

    @State private var selection = 0

    var body: some View {

        TabView(selection: $selection) {

            HStack {
                Spacer()
                VStack {
                    Spacer()
                    Text("First Tab!")
                    Spacer()
                }
                Spacer()
            }
                .background(Color.blue)
                .tabItem {
                    VStack {
                        Image("icons.general.home")
                        Text("Tab 1")
                    }
                }
                .tag(0)

            HStack {
                Spacer()
                VStack {
                    Spacer()
                    Text("Second Tab!")
                    Spacer()
                }
                Spacer()
            }
                .background(Color.red)
                .tabItem {
                    VStack {
                        Image("icons.general.list")
                        Text("Tab 2")
                    }
                }
                .tag(1)

            HStack {
                Spacer()
                VStack {
                    Spacer()
                    Text("Third Tab!")
                    Spacer()
                }
                Spacer()
            }
                .background(Color.yellow)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .tabItem {
                    VStack {
                        Image("icons.general.cog")
                        Text("Tab 3")
                    }
                }
                .tag(2)
        }
    }
}

为了解决这个问题,我整理了以下简单的自定义视图,它提供了与 iOS 更相似的选项卡界面,即使 运行 在 Mac 上也是如此。它仅通过获取一组元组来工作,每个元组概述选项卡的标题、图标名称和内容。

它可以在明暗模式下工作,并且可以在 macOS iOS / iPadOS 等系统上 运行,但你可能运行ning on iOS 时只想使用标准 TabView 实现;由你决定。

它还包含一个参数,因此您可以根据偏好将栏放在顶部或底部(顶部更符合 macOS 准则)。

这是结果示例(在深色模式下):

这是代码。一些注意事项:

  • 它使用 Color 的基本扩展,因此它可以使用系统背景颜色,而不是 hard-coding。
  • 唯一有点棘手的部分是额外的 backgroundshadow 修饰符,需要它们来防止 SwiftUI 将阴影应用到 每个子视图(!)。当然,如果你不想要阴影,你可以删除所有这些行(包括 zIndex)。

Swift v5.1:

import SwiftUI

public extension Color {

    #if os(macOS)
    static let backgroundColor = Color(NSColor.windowBackgroundColor)
    static let secondaryBackgroundColor = Color(NSColor.controlBackgroundColor)
    #else
    static let backgroundColor = Color(UIColor.systemBackground)
    static let secondaryBackgroundColor = Color(UIColor.secondarySystemBackground)
    #endif
}

public struct CustomTabView: View {
    
    public enum TabBarPosition { // Where the tab bar will be located within the view
        case top
        case bottom
    }
    
    private let tabBarPosition: TabBarPosition
    private let tabText: [String]
    private let tabIconNames: [String]
    private let tabViews: [AnyView]
    
    @State private var selection = 0
    
    public init(tabBarPosition: TabBarPosition, content: [(tabText: String, tabIconName: String, view: AnyView)]) {
        self.tabBarPosition = tabBarPosition
        self.tabText = content.map{ [=10=].tabText }
        self.tabIconNames = content.map{ [=10=].tabIconName }
        self.tabViews = content.map{ [=10=].view }
    }
    
    public var tabBar: some View {
        
        HStack {
            Spacer()
            ForEach(0..<tabText.count) { index in
                HStack {
                    Image(self.tabIconNames[index])
                    Text(self.tabText[index])
                }
                .padding()
                .foregroundColor(self.selection == index ? Color.accentColor : Color.primary)
                .background(Color.secondaryBackgroundColor)
                .onTapGesture {
                    self.selection = index
                }
            }
            Spacer()
        }
        .padding(0)
        .background(Color.secondaryBackgroundColor) // Extra background layer to reset the shadow and stop it applying to every sub-view
        .shadow(color: Color.clear, radius: 0, x: 0, y: 0)
        .background(Color.secondaryBackgroundColor)
        .shadow(
            color: Color.black.opacity(0.25),
            radius: 3,
            x: 0,
            y: tabBarPosition == .top ? 1 : -1
        )
        .zIndex(99) // Raised so that shadow is visible above view backgrounds
    }
    public var body: some View {
        
        VStack(spacing: 0) {
            
            if (self.tabBarPosition == .top) {
                tabBar
            }
            
            tabViews[selection]
                .padding(0)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            
            if (self.tabBarPosition == .bottom) {
                tabBar
            }
        }
        .padding(0)
    }
}

这是您如何使用它的示例。显然,您也可以向它传递一个完全自定义的子视图,而不是像这样即时构建它们。只需确保将它们包装在 AnyView 初始值设定项中即可。

图标及其名称是自定义的,因此您必须使用自己的替代品。

struct ContentView: View {
    
    var body: some View {
        CustomTabView(
            tabBarPosition: .top,
            content: [
                (
                    tabText: "Tab 1",
                    tabIconName: "icons.general.home",
                    view: AnyView(
                        HStack {
                            Spacer()
                            VStack {
                                Spacer()
                                Text("First Tab!")
                                Spacer()
                            }
                            Spacer()
                        }
                        .background(Color.blue)
                    )
                ),
                (
                    tabText: "Tab 2",
                    tabIconName: "icons.general.list",
                    view: AnyView(
                        HStack {
                            Spacer()
                            VStack {
                                Spacer()
                                Text("Second Tab!")
                                Spacer()
                            }
                            Spacer()
                        }
                        .background(Color.red)
                    )
                ),
                (
                    tabText: "Tab 3",
                    tabIconName: "icons.general.cog",
                    view: AnyView(
                        HStack {
                            Spacer()
                            VStack {
                                Spacer()
                                Text("Third Tab!")
                                Spacer()
                            }
                            Spacer()
                        }
                        .background(Color.yellow)
                    )
                )
            ]
        )
    }
}

您可以通过应用负填充并使用您自己的控件视图来设置可见选项卡项来简单地隐藏 TabView 的边框。

struct MyView : View
{
    @State private var selectedTab : Int = 1

    var body: some View
    {
        HSplitView
        {
            Picker("Tab Selection", selection: $selectedTab)
            {
                Text("A")
                    .tag(1)

                Text("B")
                    .tag(2)
            }


            TabView(selection: $selectedTab)
            {
                ViewA()
                    .tag(1)

                ViewB()
                    .tag(2)
            }
            // applying negative padding to hide the ugly frame
            .padding(EdgeInsets(top: -26.5, leading: -3, bottom: -3, trailing: -3))
        }
    }
}

当然,只有在 Apple 不更改 TabView 的视觉设计的情况下,此 hack 才会起作用。

回复 TheNeil(我没有足够的声誉来添加评论):

我喜欢你的解决方案,稍微修改了一下。

public struct CustomTabView: View {
    
    public enum TabBarPosition { // Where the tab bar will be located within the view
        case top
        case bottom
    }
    
    private let tabBarPosition: TabBarPosition
    private let tabText: [String]
    private let tabIconNames: [String]
    private let tabViews: [AnyView]
    
    @State private var selection = 0
    
        public init(tabBarPosition: TabBarPosition, content: [(tabText: String, tabIconName: String, view: AnyView)]) {
        self.tabBarPosition = tabBarPosition
        self.tabText = content.map{ [=10=].tabText }
        self.tabIconNames = content.map{ [=10=].tabIconName }
        self.tabViews = content.map{ [=10=].view }
        }
    
        public var tabBar: some View {
        VStack {
            Spacer()
                .frame(height: 5.0)
            HStack {
                Spacer()
                    .frame(width: 50)
                ForEach(0..<tabText.count) { index in
                    VStack {
                        Image(systemName: self.tabIconNames[index])
                            .font(.system(size: 40))
                        Text(self.tabText[index])
                    }
                    .frame(width: 65, height: 65)
                    .padding(5)
                    .foregroundColor(self.selection == index ? Color.accentColor : Color.primary)
                    .background(Color.secondaryBackgroundColor)
                    .onTapGesture {
                        self.selection = index
                    }
                    .overlay(
                        RoundedRectangle(cornerRadius: 25)
                            .fill(self.selection == index ? Color.backgroundColor.opacity(0.33) : Color.red.opacity(0.0))
                    )               .onTapGesture {
                        self.selection = index
                    }

                }
                Spacer()
            }
            .frame(alignment: .leading)
            .padding(0)
            .background(Color.secondaryBackgroundColor) // Extra background layer to reset the shadow and stop it applying to every sub-view
            .shadow(color: Color.clear, radius: 0, x: 0, y: 0)
            .background(Color.secondaryBackgroundColor)
            .shadow(
                color: Color.black.opacity(0.25),
                radius: 3,
                x: 0,
                y: tabBarPosition == .top ? 1 : -1
            )
            .zIndex(99) // Raised so that shadow is visible above view backgrounds
        }
        }

    public var body: some View {
        VStack(spacing: 0) {
                if (self.tabBarPosition == .top) {
                tabBar
            }
        
            tabViews[selection]
            .padding(0)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        
            if (self.tabBarPosition == .bottom) {
            tabBar
            }
    }
    .padding(0)
    }
}

我添加了一个屏幕截图,不知道如何让它内联显示。

modified CustomTabView