From d859007392fe4e6c1625330542402abf853b6758 Mon Sep 17 00:00:00 2001 From: Xiaoyu Liu Date: Tue, 27 Feb 2024 09:05:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20toolbar=20migration=20(#?= =?UTF-8?q?634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dyongxu <61523257+dyongxu@users.noreply.github.com> --- .../Examples.xcodeproj/project.pbxproj | 16 ++ .../FioriSwiftUICore/CoreContentView.swift | 5 + .../ToolbarExample/ToolbarExample.swift | 117 ++++++++ .../ToolbarExample/ToolbarView.swift | 198 +++++++++++++ .../Views/Toolbar/FioriToolbar.swift | 272 ++++++++++++++++++ .../Views/Toolbar/ToolbarModifier.swift | 36 +++ 6 files changed, 644 insertions(+) create mode 100644 Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarExample.swift create mode 100644 Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarView.swift create mode 100644 Sources/FioriSwiftUICore/Views/Toolbar/FioriToolbar.swift create mode 100644 Sources/FioriSwiftUICore/Views/Toolbar/ToolbarModifier.swift diff --git a/Apps/Examples/Examples.xcodeproj/project.pbxproj b/Apps/Examples/Examples.xcodeproj/project.pbxproj index ab4974e9f..f583fef50 100644 --- a/Apps/Examples/Examples.xcodeproj/project.pbxproj +++ b/Apps/Examples/Examples.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ B18D593C2B0C52C700ABB1AD /* TabViewExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B18D593B2B0C52C700ABB1AD /* TabViewExample.swift */; }; B1BA1F922B19AAEE00E6C052 /* TabViewDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1BA1F912B19AAEE00E6C052 /* TabViewDetailView.swift */; }; B1BA1F942B1EC36100E6C052 /* 08 Tap.wav in Resources */ = {isa = PBXBuildFile; fileRef = B1BA1F932B1EC36100E6C052 /* 08 Tap.wav */; }; + B1BA1F972B2167DC00E6C052 /* ToolbarExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1BA1F962B2167DC00E6C052 /* ToolbarExample.swift */; }; B1C7DC8129FBB13F00DC5EEB /* SPIModelExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C7DC8029FBB13F00DC5EEB /* SPIModelExample.swift */; }; B1D41B20291A2D97004E64A5 /* DurationPickerExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D41B1F291A2D97004E64A5 /* DurationPickerExample.swift */; }; B1D9D22A2991E056008FF5BC /* ObjectItemAvatarsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D9D2292991E056008FF5BC /* ObjectItemAvatarsExample.swift */; }; @@ -87,6 +88,7 @@ B1DD86512B07534D00D7EDFD /* NavigationBarFioriStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DD86502B07534D00D7EDFD /* NavigationBarFioriStyle.swift */; }; B1DD86532B0758F000D7EDFD /* NavigationBarPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DD86522B0758F000D7EDFD /* NavigationBarPopover.swift */; }; B1DD86552B0759DD00D7EDFD /* NavigationBarCustomItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DD86542B0759DD00D7EDFD /* NavigationBarCustomItem.swift */; }; + B1F6FC302B22BDDA005190F9 /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F6FC2F2B22BDDA005190F9 /* ToolbarView.swift */; }; B80DA9BA260BBF8600C0B2E9 /* SingleActionProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80DA9B9260BBF8600C0B2E9 /* SingleActionProfiles.swift */; }; B80DA9BC260BED9400C0B2E9 /* SingleActionCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80DA9BB260BED9400C0B2E9 /* SingleActionCollectionView.swift */; }; B80DA9BE260C1CC200C0B2E9 /* ListDataProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80DA9BD260C1CC200C0B2E9 /* ListDataProtocol.swift */; }; @@ -243,6 +245,7 @@ B18D593B2B0C52C700ABB1AD /* TabViewExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabViewExample.swift; sourceTree = ""; }; B1BA1F912B19AAEE00E6C052 /* TabViewDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewDetailView.swift; sourceTree = ""; }; B1BA1F932B1EC36100E6C052 /* 08 Tap.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "08 Tap.wav"; sourceTree = ""; }; + B1BA1F962B2167DC00E6C052 /* ToolbarExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarExample.swift; sourceTree = ""; }; B1C7DC8029FBB13F00DC5EEB /* SPIModelExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SPIModelExample.swift; sourceTree = ""; }; B1D41B1F291A2D97004E64A5 /* DurationPickerExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationPickerExample.swift; sourceTree = ""; }; B1D9D2292991E056008FF5BC /* ObjectItemAvatarsExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectItemAvatarsExample.swift; sourceTree = ""; }; @@ -250,6 +253,7 @@ B1DD86502B07534D00D7EDFD /* NavigationBarFioriStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarFioriStyle.swift; sourceTree = ""; }; B1DD86522B0758F000D7EDFD /* NavigationBarPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarPopover.swift; sourceTree = ""; }; B1DD86542B0759DD00D7EDFD /* NavigationBarCustomItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarCustomItem.swift; sourceTree = ""; }; + B1F6FC2F2B22BDDA005190F9 /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; B80DA9B9260BBF8600C0B2E9 /* SingleActionProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleActionProfiles.swift; sourceTree = ""; }; B80DA9BB260BED9400C0B2E9 /* SingleActionCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleActionCollectionView.swift; sourceTree = ""; }; B80DA9BD260C1CC200C0B2E9 /* ListDataProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListDataProtocol.swift; sourceTree = ""; }; @@ -475,6 +479,7 @@ 99658F862B7C35630026A743 /* Indicator */, B88CB6102B716C0300013B37 /* Card */, C18868CF2B3252F400F865F7 /* SearchBar */, + B1BA1F952B2167BE00E6C052 /* ToolbarExample */, B1BA1F902B19A8B500E6C052 /* TabViewExample */, B1CC61C52AFA0856002078C1 /* NavigationBar */, C1C764862A818BD600BCB0F7 /* SortFilter */, @@ -580,6 +585,15 @@ path = TabViewExample; sourceTree = ""; }; + B1BA1F952B2167BE00E6C052 /* ToolbarExample */ = { + isa = PBXGroup; + children = ( + B1BA1F962B2167DC00E6C052 /* ToolbarExample.swift */, + B1F6FC2F2B22BDDA005190F9 /* ToolbarView.swift */, + ); + path = ToolbarExample; + sourceTree = ""; + }; B1CC61C52AFA0856002078C1 /* NavigationBar */ = { isa = PBXGroup; children = ( @@ -825,6 +839,7 @@ B8101D52268BB84B00D32560 /* ContactItemTapStateExamples.swift in Sources */, 8A55795724C1286E0098003A /* AppDelegate.swift in Sources */, C106AD4A2B33970500FE8B35 /* SearchPromptFontAndColor.swift in Sources */, + B1BA1F972B2167DC00E6C052 /* ToolbarExample.swift in Sources */, B18D2E9F2988B07B000A1821 /* KPIHeaderExample.swift in Sources */, 8AB6C01428DF6583002F32BE /* LazyView.swift in Sources */, 691DE21925F2A30B00094D4A /* KPIViewExample.swift in Sources */, @@ -917,6 +932,7 @@ 8A557A2224C12C9B0098003A /* CoreContentView.swift in Sources */, 8A5579D224C1293C0098003A /* Color+Extensions.swift in Sources */, C1C764882A818BEC00BCB0F7 /* SortFilterExample.swift in Sources */, + B1F6FC302B22BDDA005190F9 /* ToolbarView.swift in Sources */, B84D24EF2652F343007F2373 /* ObjectHeaderTestApp.swift in Sources */, B84D24EC2652F343007F2373 /* ObjectHeaderSpecCompact.swift in Sources */, 8A5579CD24C1293C0098003A /* SettingsLabel.swift in Sources */, diff --git a/Apps/Examples/Examples/FioriSwiftUICore/CoreContentView.swift b/Apps/Examples/Examples/FioriSwiftUICore/CoreContentView.swift index 59170a798..d7b02c984 100644 --- a/Apps/Examples/Examples/FioriSwiftUICore/CoreContentView.swift +++ b/Apps/Examples/Examples/FioriSwiftUICore/CoreContentView.swift @@ -48,6 +48,11 @@ struct CoreContentView: View { destination: TabViewExample()) { Text("Customized TabView") } + + NavigationLink( + destination: ToolbarExample()) { + Text("Customized Toolbar") + } } NavigationLink( diff --git a/Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarExample.swift b/Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarExample.swift new file mode 100644 index 000000000..c1bfb4ddb --- /dev/null +++ b/Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarExample.swift @@ -0,0 +1,117 @@ +import FioriSwiftUICore +import SwiftUI + +struct ToolbarExample: View { + @State var isPresented: Bool = false + @State var isPresented2: Bool = false + @State var numberOfButtons: Int = 2 + @State var useFioriToolbar: Bool = true + @State var helperText: String = "" + @State var customHelperText: Bool = false + @State var customOverflowIcon: Bool = false + @State var primaryButton: String = "" + @State var secondaryButton: String = "" + @State var thirdButton: String = "" + @State var buttonType: ItemStyle = .fiori + + var body: some View { + Form { + HStack { + Text("Selecte to Test") + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + isPresented.toggle() + } + .sheet(isPresented: $isPresented) { + NavigationStack { + ToolbarView(numberOfButtons: $numberOfButtons, useFioriToolbar: $useFioriToolbar, helperText: $helperText, customHelperText: $customHelperText, customOverflowIcon: $customOverflowIcon, primaryButtonText: $primaryButton, secondaryButtonText: $secondaryButton, thirdButtonText: $thirdButton, buttonType: $buttonType) + } + } + + Picker("Number of Buttons", selection: $numberOfButtons) { + ForEach(0 ..< 8, id: \.self) { index in + Text("\(index + 1)").tag(index + 1) + } + } + + Toggle("Use FioriToolbar", isOn: $useFioriToolbar) + + Picker("Button Type", selection: $buttonType) { + Text("Fiori Button").tag(ItemStyle.fiori) + Text("Icon").tag(ItemStyle.icon) + Text("SiwftUI Button").tag(ItemStyle.button) + } + + Picker("Helper Text", selection: $helperText) { + Text("None").tag("") + Text("Short").tag("Helper Text") + Text("Long").tag("Long Long Long Long Long Helper Text") + Text("Extra Long").tag("Extra Extra Extra Extra Extra Extra Extra Long Long Long Long Long Helper Text") + Text("Extra Extra Long").tag("Extra Extra Extra Extra Extra Extra Extra Extra Extra Extra Extra Extra Long Long Long Long Long Helper Text") + } + + Group { + Toggle("Custom Helper Text Color & Font", isOn: $customHelperText) + + Toggle("Custom Overflow Icon", isOn: $customOverflowIcon) + } + + Picker("Primary Button", selection: $primaryButton) { + Text("None").tag("") + Text("Long Primary Button").tag("Long Long Primary Button Title") + Text("Extra Long Primary Button").tag("Extra Long Long Long Long Long Long Long Long Long Long Long Long Primary Button Title") + } + + Picker("Secondary Button", selection: $secondaryButton) { + Text("None").tag("") + Text("Long Secondary Button").tag("Long Secondary Button Title") + Text("Extra Long Secondary Button").tag("Extra Long Long Long Long Long Long Long LongLong Long Secondary Button Title") + } + + Picker("3rd Button", selection: $thirdButton) { + Text("None").tag("") + Text("Long 3rd Button").tag("Long Long Long Button Title") + Text("Extra Long 3rd Button").tag("Extra Long Long Long Long Long Long Long LongLong Long Long Button Title") + } + + Section { + HStack { + Text("Special example that toolbar filled with items") + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + isPresented2.toggle() + } + .sheet(isPresented: $isPresented2) { + NavigationStack { + Color.preferredColor(.grey7) + .overlay { + Text("This is an example that toolbar filled with expandable buttons") + } + .fioriToolbar { + HStack { + FioriButton { _ in + Text("Save") + .frame(maxWidth: .infinity) + } + FioriButton { _ in + Text("Submit") + .frame(maxWidth: .infinity) + } + } + } + } + } + } + } + } +} + +#Preview { + NavigationStack { + ToolbarExample() + } +} diff --git a/Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarView.swift b/Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarView.swift new file mode 100644 index 000000000..0d3d29db9 --- /dev/null +++ b/Apps/Examples/Examples/FioriSwiftUICore/ToolbarExample/ToolbarView.swift @@ -0,0 +1,198 @@ +import FioriSwiftUICore +import FioriThemeManager +import SwiftUI + +enum ItemStyle { + case fiori + case button + case icon +} + +struct ToolbarView: View { + @Binding var numberOfButtons: Int + @Binding var useFioriToolbar: Bool + @Binding var helperText: String + @Binding var customHelperText: Bool + @Binding var customOverflowIcon: Bool + @Binding var primaryButtonText: String + @Binding var secondaryButtonText: String + @Binding var thirdButtonText: String + @Binding var buttonType: ItemStyle + + var body: some View { + if !useFioriToolbar { + Color.preferredColor(.grey7) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Text("\(helperText)") + ForEach(0 ..< numberOfButtons, id: \.self) { index in + createButton(at: index) + } + } + } + } else { + switch numberOfButtons { + case 1: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + } + case 2: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + createButton(at: 1) + } + case 3: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + createButton(at: 1) + createButton(at: 2) + } + case 4: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + createButton(at: 1) + createButton(at: 2) + createButton(at: 3) + } + case 5: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + createButton(at: 1) + createButton(at: 2) + createButton(at: 3) + createButton(at: 4) + } + case 6: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + createButton(at: 1) + createButton(at: 2) + createButton(at: 3) + createButton(at: 4) + createButton(at: 5) + } + case 7: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + createButton(at: 1) + createButton(at: 2) + createButton(at: 3) + createButton(at: 4) + createButton(at: 5) + createButton(at: 6) + } + case 8: + Color.preferredColor(.grey7) + .fioriToolbar(helperText: createHelperText(), + customOverflow: customOverflowIcon ? Image(systemName: "person") : nil) { + createButton(at: 0) + createButton(at: 1) + createButton(at: 2) + createButton(at: 3) + createButton(at: 4) + createButton(at: 5) + createButton(at: 6) + createButton(at: 7) + } + default: + Color.preferredColor(.grey7) + } + } + } + + var buttonTexts = ["Save", "Submit", "Long Long Long Button Title", + "Extra Long Long Long Long Long Long Long LongLong Long Long Button Title", + "Button 5", "Button 6", "Button 7", "Button 8"] + var iconNames = ["square.and.arrow.up", "square.and.arrow.down", + "pencil", "paperclip", "delete.left", "plus.app", + "trash", "phone"] + + @ViewBuilder + func createButton(at index: Int = 0) -> some View { + switch self.buttonType { + case .fiori, .button: + let button = self.buttons()[index] + if button.type == 1 { + FioriButton { _ in + Text(button.title) + } + .fioriButtonStyle(FioriPrimaryButtonStyle().eraseToAnyFioriButtonStyle()) + } else if button.type == 2 { + FioriButton { _ in + Text(button.title) + } + .fioriButtonStyle(FioriSecondaryButtonStyle().eraseToAnyFioriButtonStyle()) + } else if button.type == 3 { + FioriButton { _ in + Text(button.title) + } + .fioriButtonStyle(FioriTertiaryButtonStyle().eraseToAnyFioriButtonStyle()) + } else { + if self.buttonType == .fiori { + FioriButton { _ in + Text(button.title) + } + .fioriButtonStyle(FioriSecondaryButtonStyle().eraseToAnyFioriButtonStyle()) + } else { + Button(action: {}, label: { + Text(button.title) + }) + } + } + case .icon: + Image(systemName: self.iconNames[index]) + .foregroundStyle(Color.preferredColor(.tintColor)) + .fontWeight(.semibold) + } + } + + func buttons() -> [(type: Int, title: String)] { + var mergedButtons = [(Int, String)]() + if !self.primaryButtonText.isEmpty { + mergedButtons.append((1, self.primaryButtonText)) + } + if !self.secondaryButtonText.isEmpty { + mergedButtons.append((2, self.secondaryButtonText)) + } + if !self.thirdButtonText.isEmpty { + mergedButtons.append((3, self.thirdButtonText)) + } + mergedButtons.append(contentsOf: self.buttonTexts.map { (0, $0) }) + return mergedButtons + } + + @ViewBuilder + func createHelperText() -> some View { + if self.helperText.isEmpty { + EmptyView() + } else { + if self.customHelperText { + Text(self.helperText).font(Font.fiori(forTextStyle: .headline)).foregroundStyle(Color.preferredColor(.red7)) + } else { + Text(self.helperText).font(Font.fiori(forTextStyle: .caption1)) + .foregroundStyle(Color.preferredColor(.tertiaryLabel).opacity(0.9)) + } + } + } +} + +#Preview { + NavigationStack { + let a = "Extra Extra Extra Long Long Long Long Long Helper Text" + ToolbarView(numberOfButtons: .constant(1), useFioriToolbar: .constant(true), helperText: .constant(""), customHelperText: .constant(true), customOverflowIcon: .constant(false), primaryButtonText: .constant(""), secondaryButtonText: .constant(""), thirdButtonText: .constant(""), buttonType: .constant(.fiori)) + } +} diff --git a/Sources/FioriSwiftUICore/Views/Toolbar/FioriToolbar.swift b/Sources/FioriSwiftUICore/Views/Toolbar/FioriToolbar.swift new file mode 100644 index 000000000..92dbc5d50 --- /dev/null +++ b/Sources/FioriSwiftUICore/Views/Toolbar/FioriToolbar.swift @@ -0,0 +1,272 @@ +import FioriThemeManager +import SwiftUI + +struct FioriToolbar: ViewModifier { + var helperText: (any View)? + let items: Items + var customOverflow: (any View)? + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @ObservedObject var sizeHandler = FioriToolbarHandler() + + init(helperText: (any View)? = nil, + customOverflow: (any View)? = nil, + @IndexedViewBuilder items: () -> Items) + { + self.helperText = helperText + self.customOverflow = customOverflow + self.items = items() + } + + init(helperText: String, + customOverflow: (any View)? = nil, + @IndexedViewBuilder items: () -> Items) + { + self.init(helperText: helperText.isEmpty ? nil : Text(helperText).foregroundStyle(Color.preferredColor(.tertiaryLabel).opacity(0.9)).font(Font.fiori(forTextStyle: .caption1)), + customOverflow: customOverflow, + items: items) + } + + init(customOverflow: (any View)? = nil, + @IndexedViewBuilder items: () -> Items) + { + self.init(helperText: nil, + customOverflow: customOverflow, + items: items) + } + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItemGroup(placement: .bottomBar) { + if sizeHandler.needLayoutSubviews { + HStack(spacing: 0) { + ForEach(0 ..< sizeHandler.itemsWidth.count, id: \.self) { index in + let itemIndex = sizeHandler.itemsWidth[index].0 + let itemWidth = sizeHandler.itemsWidth[index].1 + if itemIndex >= 0 { + items.view(at: itemIndex) + .frame(width: itemWidth) + } else { + if itemIndex == -1 { + helperTextView() + .frame(width: abs(itemWidth)) + } else if itemIndex == -2 { + moreAction() + .frame(width: itemWidth) + } + } + if index < sizeHandler.itemsWidth.count - 1 { + if itemIndex == -1 || !sizeHandler.useFixedPadding { + Spacer().frame(minWidth: 8) + } else { + Spacer().frame(width: sizeHandler.defaultFixedPadding) + } + } + } + } + } else { + HStack(spacing: sizeHandler.defaultFixedPadding) { + if helperText != nil { + helperTextView() + .sizeReader { size in + sizeHandler.helperTextWidth = size.width + } + } + ForEach(0 ..< items.count, + id: \.self) { index in + items.view(at: index) + .sizeReader { size in + sizeHandler.itemsSize[index] = size + } + }.background { + moreAction() + .sizeReader(size: { size in + sizeHandler.totalItemsCount = items.count + sizeHandler.moreActionWidth = size.width + }) + .hidden() + } + } + } + } + } + .sizeReader { size in + sizeHandler.containerSize = size + if horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .pad { + sizeHandler.rtlMargin = 40 + } else { + sizeHandler.rtlMargin = 160 + } + } + } + + @ViewBuilder + func moreAction() -> some View { + Menu { + if !sizeHandler.moreActionsIndex.isEmpty { + ForEach(sizeHandler.moreActionsIndex, id: \.self) { index in + items.view(at: index) + } + } else { + ForEach(0 ..< items.count, id: \.self) { index in + items.view(at: index) + } + } + } label: { + if let overflowView = customOverflow { + overflowView.typeErased + } else { + Image(systemName: "ellipsis") + } + } + } + + @ViewBuilder + func helperTextView() -> some View { + Group { + if let text = helperText { + text.typeErased.lineLimit(2) + } else { + EmptyView() + } + }.frame(height: 44) + } +} + +#Preview { + NavigationStack { + Color.preferredColor(.red1) + .fioriToolbar(helperText: "helper text", + items: { + Button(action: {}, label: { + Text("This is a very very very very very long button title") + }) + Button(action: {}, label: { + Text("Save") + }) + Button(action: {}, label: { + Text("Submit") + }) + }) + } +} + +class FioriToolbarHandler: ObservableObject { + var containerSize: CGSize = .zero { + didSet { + self.calculateItemsSize() + } + } + + var totalItemsCount = 0 + + var itemsSize: [Int: CGSize] = [:] { + didSet { + self.calculateItemsSize() + } + } + + var helperTextWidth: CGFloat = 0 { + didSet { + self.calculateItemsSize() + } + } + + var moreActionWidth: CGFloat = 0 { + didSet { + self.calculateItemsSize() + } + } + + var showFirstItem = true + var needLayoutSubviews = false + var moreActionsIndex: [Int] = [] + + var useFixedPadding: Bool = true + var rtlMargin: CGFloat = 40 + let defaultFixedPadding: CGFloat = 8 + private let minHelperTextWidth: CGFloat = 64 + // [index: width] when index is -1, helper text, -2 is overflow action + var itemsWidth: [(Int, CGFloat)] = [] + + // swiftlint:disable:next cyclomatic_complexity function_body_length + func calculateItemsSize() { + guard self.totalItemsCount == self.itemsSize.count, self.containerSize.width > 0 else { return } + self.moreActionsIndex.removeAll() + self.itemsWidth.removeAll() + self.useFixedPadding = true + let availableItemWidth: CGFloat + if self.helperTextWidth > 0 { + availableItemWidth = self.containerSize.width - self.rtlMargin - min(self.minHelperTextWidth, self.helperTextWidth) - self.defaultFixedPadding + } else { + availableItemWidth = self.containerSize.width - self.rtlMargin + } + + switch self.itemsSize.count { + case 0: + return + case 1: + if self.helperTextWidth > 0 { + if let itemWidth = itemsSize[0]?.width { + if itemWidth > availableItemWidth { + self.itemsWidth = [(-1, min(self.minHelperTextWidth, self.helperTextWidth)), (0, availableItemWidth)] + } else { + self.itemsWidth = [(-1, .infinity), (0, itemWidth)] + } + } + self.useFixedPadding = false + } else { + self.itemsWidth = [(0, .infinity)] + } + self.needLayoutSubviews = true + objectWillChange.send() + default: + var textWidth: CGFloat = 0 + var currentWidth: CGFloat = 0 + var allWidth = self.itemsSize.sorted(by: { $0.key < $1.key }).map(\.value.width).reduce(0, +) + allWidth += CGFloat(self.itemsSize.count - 1) * self.defaultFixedPadding + if allWidth > availableItemWidth { + // need more action menu + self.itemsWidth.append((-2, self.moreActionWidth)) + + currentWidth += (self.moreActionWidth + self.defaultFixedPadding) + var noItemForAvailableWidth = true + for item in self.itemsSize.sorted(by: { $0.key < $1.key }) { + currentWidth += item.value.width + if currentWidth > availableItemWidth { + self.moreActionsIndex.append(item.key) + currentWidth -= item.value.width + } else { + self.itemsWidth.append((item.key, item.value.width)) + currentWidth += self.defaultFixedPadding + noItemForAvailableWidth = false + } + } + + if noItemForAvailableWidth { + // truncated first item + let firstItemWidth = availableItemWidth - self.moreActionWidth - self.defaultFixedPadding + self.itemsWidth.append((0, firstItemWidth)) + textWidth = self.minHelperTextWidth + } else { + textWidth = self.containerSize.width - self.rtlMargin - currentWidth + self.defaultFixedPadding + } + } else { + self.itemsWidth = self.itemsSize.sorted(by: { $0.key < $1.key }).map { ($0.key, $0.value.width) } + textWidth = self.containerSize.width - self.rtlMargin - allWidth - (self.helperTextWidth > 0 ? self.defaultFixedPadding : 0) + self.useFixedPadding = false + } + + if self.helperTextWidth > 0 { + if self.itemsSize.count == 2, self.moreActionsIndex.isEmpty { + self.itemsWidth.insert((-1, min(self.helperTextWidth, textWidth)), at: 1) + } else { + self.itemsWidth.insert((-1, min(self.helperTextWidth, textWidth)), at: 0) + } + } else { + self.useFixedPadding = false + } + self.needLayoutSubviews = true + objectWillChange.send() + } + } +} diff --git a/Sources/FioriSwiftUICore/Views/Toolbar/ToolbarModifier.swift b/Sources/FioriSwiftUICore/Views/Toolbar/ToolbarModifier.swift new file mode 100644 index 000000000..921588405 --- /dev/null +++ b/Sources/FioriSwiftUICore/Views/Toolbar/ToolbarModifier.swift @@ -0,0 +1,36 @@ +import SwiftUI + +public extension View { + /// A toolbar modifier for fiori style. + /// - Parameters: + /// - helperText: A string for helper text displayed in toolbar stack view. + /// - customOverflow: A custom overflow label for wrapped items menu. + /// - items: Indexed views for toolbar items + /// - Returns: A new view with a bottom tool bar. + func fioriToolbar(helperText: String? = nil, customOverflow: (any View)? = nil, @IndexedViewBuilder items: () -> Items) -> some View { + if let text = helperText, !text.isEmpty { + self.modifier(FioriToolbar(helperText: text, customOverflow: customOverflow, items: items)) + } else { + self.modifier(FioriToolbar(customOverflow: customOverflow, items: items)) + } + } + + /// A toolbar modifier for fiori style. + /// - Parameters: + /// - helperText: A helper text container displayed in toolbar stack view. + /// - customOverflow: A custom overflow label for wrapped items menu. + /// - items: Indexed views for toolbar items + /// - Returns: A new view with a bottom tool bar. + func fioriToolbar(helperText: (any View)?, customOverflow: (any View)? = nil, @IndexedViewBuilder items: () -> Items) -> some View { + self.modifier(FioriToolbar(helperText: helperText, customOverflow: customOverflow, items: items)) + } + + /// A toolbar modifier for fiori style. + /// - Parameters: + /// - customOverflow: A custom overflow label for wrapped items menu. + /// - items: Indexed views for toolbar items + /// - Returns: A new view with a bottom tool bar. + func fioriToolbar(customOverflow: (any View)? = nil, @IndexedViewBuilder items: () -> Items) -> some View { + self.modifier(FioriToolbar(customOverflow: customOverflow, items: items)) + } +}