diff --git a/Apps/Examples/Examples/FioriSwiftUICore/SortFilter/SortFilterExample.swift b/Apps/Examples/Examples/FioriSwiftUICore/SortFilter/SortFilterExample.swift index 9a72c8d95..d0c8290be 100644 --- a/Apps/Examples/Examples/FioriSwiftUICore/SortFilter/SortFilterExample.swift +++ b/Apps/Examples/Examples/FioriSwiftUICore/SortFilter/SortFilterExample.swift @@ -6,7 +6,7 @@ struct SortFilterExample: View { [ .switch(item: .init(name: "Favorite", value: true, icon: "heart.fill"), showsOnFilterFeedbackBar: true), .switch(item: .init(name: "Tagged", value: nil, icon: "tag"), showsOnFilterFeedbackBar: false), - .picker(item: .init(name: "JIRA Status", value: [0], valueOptions: ["Received", "Started", "Hold", "Transfer", "Completed", "Pending Review Pending Pending Pending Pending Pending", "Accepted Medium", "Rejected"], allowsMultipleSelection: true, allowsEmptySelection: true, showsValueForSingleSelected: false, icon: "clock", itemLayout: .flexible), showsOnFilterFeedbackBar: true) + .picker(item: .init(name: "JIRA Status", value: [0], valueOptions: ["Received", "Started", "Hold", "Transfer", "Completed", "Pending Review Pending Pending Pending Pending Pending", "Accepted Medium", "Pending Medium", "Completed Medium"], allowsMultipleSelection: true, allowsEmptySelection: true, showsValueForSingleSelected: false, icon: "clock", itemLayout: .fixed, displayMode: .automatic), showsOnFilterFeedbackBar: true) ], [ .picker(item: .init(name: "Priority", value: [0], valueOptions: ["High", "Medium", "Low"], allowsMultipleSelection: true, allowsEmptySelection: true, showsValueForSingleSelected: false, icon: "filemenu.and.cursorarrow"), showsOnFilterFeedbackBar: true), diff --git a/Sources/FioriSwiftUICore/Components/CancellableResettableForm.swift b/Sources/FioriSwiftUICore/Components/CancellableResettableForm.swift index 06404f26f..c34e34a8d 100644 --- a/Sources/FioriSwiftUICore/Components/CancellableResettableForm.swift +++ b/Sources/FioriSwiftUICore/Components/CancellableResettableForm.swift @@ -43,6 +43,53 @@ struct CancellableResettableDialogForm: View { + let title: Title + + let components: Components + + var cancelAction: CancelAction + var resetAction: ResetAction + var applyAction: ApplyAction + + public init(@ViewBuilder title: () -> Title, + @ViewBuilder cancelAction: () -> CancelAction, + @ViewBuilder resetAction: () -> ResetAction, + @ViewBuilder applyAction: () -> ApplyAction, + @ViewBuilder components: () -> Components) + { + self.title = title() + self.cancelAction = cancelAction() + self.resetAction = resetAction() + self.applyAction = applyAction() + self.components = components() + } + + var body: some View { + NavigationStack { + VStack(spacing: UIDevice.current.userInterfaceIdiom == .pad ? 8 : 16) { + self.components.background(Color.preferredColor(.secondaryGroupedBackground)) + self.applyAction + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + self.title + } + ToolbarItem(placement: .topBarLeading) { + self.cancelAction + } + ToolbarItem(placement: .topBarTrailing) { + self.resetAction + } + } + } + .frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 375 : Screen.bounds.size.width) + .padding([.bottom], UIDevice.current.userInterfaceIdiom == .pad ? 13 : 16) + .background(Color.preferredColor(.chromeSecondary)) + } +} + struct ApplyButtonStyle: PrimitiveButtonStyle { @Environment(\.isEnabled) private var isEnabled: Bool diff --git a/Sources/FioriSwiftUICore/DataTypes/SortFilter+DataType.swift b/Sources/FioriSwiftUICore/DataTypes/SortFilter+DataType.swift index 973262812..33473f377 100644 --- a/Sources/FioriSwiftUICore/DataTypes/SortFilter+DataType.swift +++ b/Sources/FioriSwiftUICore/DataTypes/SortFilter+DataType.swift @@ -57,7 +57,7 @@ public enum SortFilterItem: Identifiable, Hashable { /// /// 2. A section of view containing a SwiftUI Canlendar case datetime(item: DateTimeItem, showsOnFilterFeedbackBar: Bool) - + public var showsOnFilterFeedbackBar: Bool { switch self { case .picker(_, let showsOnFilterFeedbackBar): @@ -311,9 +311,23 @@ public extension SortFilterItem { public let allowsEmptySelection: Bool public var showsValueForSingleSelected: Bool = true public let icon: String? + /// itemLayout is used when listPickerMode is filterFormCell, otherwise is ignored. public var itemLayout: OptionListPickerItemLayoutType = .fixed - - public init(id: String = UUID().uuidString, name: String, value: [Int], valueOptions: [String], allowsMultipleSelection: Bool, allowsEmptySelection: Bool, showsValueForSingleSelected: Bool = true, icon: String? = nil, itemLayout: OptionListPickerItemLayoutType = .fixed) { + public var displayMode: DisplayMode = .automatic + + /// Available OptionListPicker modes. Use this enum to define picker mode to present. + public enum DisplayMode { + /// Decided by options count + case automatic + /// FilterFormCell + case filterFormCell + /// Menu + case menu + /// List + case list + } + + public init(id: String = UUID().uuidString, name: String, value: [Int], valueOptions: [String], allowsMultipleSelection: Bool, allowsEmptySelection: Bool, showsValueForSingleSelected: Bool = true, icon: String? = nil, itemLayout: OptionListPickerItemLayoutType = .fixed, displayMode: DisplayMode = .automatic) { self.id = id self.name = name self.value = value @@ -325,6 +339,7 @@ public extension SortFilterItem { self.showsValueForSingleSelected = showsValueForSingleSelected self.icon = icon self.itemLayout = itemLayout + self.displayMode = displayMode } mutating func onTap(option: String) { @@ -389,6 +404,15 @@ public extension SortFilterItem { self.workingValue.contains(index) } + mutating func selectAll(_ isAll: Bool) { + self.workingValue.removeAll() + if isAll { + for i in 0 ..< self.valueOptions.count { + self.workingValue.append(i) + } + } + } + var isChecked: Bool { !self.value.isEmpty } diff --git a/Sources/FioriSwiftUICore/Models/ModelDefinitions.swift b/Sources/FioriSwiftUICore/Models/ModelDefinitions.swift index ed60a9d48..68d5fd067 100644 --- a/Sources/FioriSwiftUICore/Models/ModelDefinitions.swift +++ b/Sources/FioriSwiftUICore/Models/ModelDefinitions.swift @@ -508,6 +508,7 @@ public protocol FilterFeedbackBarButtonModel: LeftIconComponent, TitleComponent // sourcery: add_env_props = "filterFeedbackBarStyle" // sourcery: generated_component_not_configurable +// sourcery: virtualPropHeight = "@State var _height: CGFloat = 0" public protocol OptionListPickerItemModel: OptionListPickerComponent { // sourcery: default.value = .fixed // sourcery: no_view @@ -518,6 +519,21 @@ public protocol OptionListPickerItemModel: OptionListPickerComponent { var onTap: ((_ index: Int) -> Void)? { get } } +// sourcery: add_env_props = "filterFeedbackBarStyle" +// sourcery: generated_component_not_configurable +// sourcery: virtualPropHeight = "@State var _height: CGFloat = 44" +// sourcery: virtualPropSearchText = "@State var _searchText: String = """ +// sourcery: virtualPropSearchViewCornerRadius = "@State var _searchViewCornerRadius: CGFloat = 18" +// sourcery: virtualPropSelectAll = "var selectAll: ((Bool) -> ())? = nil" +// sourcery: virtualPropUpdateSearchListPickerHeight = "var updateSearchListPickerHeight: ((CGFloat) -> ())? = nil" +// sourcery: virtualPropAllowsMultipleSelection = "var allowsMultipleSelection: Bool = false" +// sourcery: virtualPropAllowsEmptySelection = "var allowsEmptySelection: Bool = false" +public protocol SearchListPickerItemModel: OptionListPickerComponent { + // sourcery: default.value = nil + // sourcery: no_view + var onTap: ((_ index: Int) -> Void)? { get } +} + // sourcery: add_env_props = "filterFeedbackBarStyle" // sourcery: generated_component_not_configurable // sourcery: add_env_props = "fioriToggleStyle" diff --git a/Sources/FioriSwiftUICore/Views/OptionListPickerItem+View.swift b/Sources/FioriSwiftUICore/Views/OptionListPickerItem+View.swift index 19a9ea8b3..3b7a2a749 100644 --- a/Sources/FioriSwiftUICore/Views/OptionListPickerItem+View.swift +++ b/Sources/FioriSwiftUICore/Views/OptionListPickerItem+View.swift @@ -1,4 +1,18 @@ import SwiftUI +import UIKit + +struct StatusBar { + private init() {} + + static var height: CGFloat { + #if os(visionOS) + 44 // default statusBar height for visionOS + #else + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return 0 } + return windowScene.statusBarManager?.statusBarFrame.height ?? 0 + #endif + } +} /// Available OptionListPickerItem layout types. Use this enum to define item layout type to present. public enum OptionListPickerItemLayoutType { @@ -18,45 +32,75 @@ extension OptionListPickerItem: View { } private func generateFixedContent() -> some View { - Grid(horizontalSpacing: 16) { - ForEach(0 ..< Int(ceil(Double(_valueOptions.count) / 2.0)), id: \.self) { rowIndex in - GridRow { - FilterFeedbackBarButton( - leftIcon: _value.wrappedValue.contains(rowIndex * 2) ? Image(systemName: "checkmark") : nil, - title: _valueOptions[rowIndex * 2], - isSelected: _value.wrappedValue.contains(rowIndex * 2) - ) - .onTapGesture { - _onTap?(rowIndex * 2) - } - if rowIndex * 2 + 1 < _valueOptions.count { + ScrollView(.vertical) { + Grid(horizontalSpacing: 16) { + ForEach(0 ..< Int(ceil(Double(_valueOptions.count) / 2.0)), id: \.self) { rowIndex in + GridRow { FilterFeedbackBarButton( - leftIcon: _value.wrappedValue.contains(rowIndex * 2 + 1) ? Image(systemName: "checkmark") : nil, - title: _valueOptions[rowIndex * 2 + 1], - isSelected: _value.wrappedValue.contains(rowIndex * 2 + 1) + leftIcon: _value.wrappedValue.contains(rowIndex * 2) ? Image(systemName: "checkmark") : nil, + title: _valueOptions[rowIndex * 2], + isSelected: _value.wrappedValue.contains(rowIndex * 2) ) .onTapGesture { - _onTap?(rowIndex * 2 + 1) + _onTap?(rowIndex * 2) + } + if rowIndex * 2 + 1 < _valueOptions.count { + FilterFeedbackBarButton( + leftIcon: _value.wrappedValue.contains(rowIndex * 2 + 1) ? Image(systemName: "checkmark") : nil, + title: _valueOptions[rowIndex * 2 + 1], + isSelected: _value.wrappedValue.contains(rowIndex * 2 + 1) + ) + .onTapGesture { + _onTap?(rowIndex * 2 + 1) + } } } } } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + let popverHeight = Screen.bounds.size.height - StatusBar.height + let totalSpacing: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad ? 8 : 16) * 2 + let totalPadding: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad ? 13 : 16) * 2 + let maxScrollViewHeight = popverHeight - totalSpacing - totalPadding - 120 + self._height = min(geometry.size.height, maxScrollViewHeight) + } + } + ) } + .frame(height: _height) } private func generateFlexibleContent() -> some View { - OptionListPickerCustomLayout { - ForEach(0 ..< _valueOptions.count, id: \.self) { optionIndex in - FilterFeedbackBarButton( - leftIcon: _value.wrappedValue.contains(optionIndex) ? Image(systemName: "checkmark") : nil, - title: _valueOptions[optionIndex], - isSelected: _value.wrappedValue.contains(optionIndex) - ) - .onTapGesture { - _onTap?(optionIndex) + ScrollView(.vertical) { + OptionListPickerCustomLayout { + ForEach(0 ..< _valueOptions.count, id: \.self) { optionIndex in + FilterFeedbackBarButton( + leftIcon: _value.wrappedValue.contains(optionIndex) ? Image(systemName: "checkmark") : nil, + title: _valueOptions[optionIndex], + isSelected: _value.wrappedValue.contains(optionIndex) + ) + .onTapGesture { + _onTap?(optionIndex) + } } } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + let popverHeight = Screen.bounds.size.height - StatusBar.height + let totalSpacing: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad ? 8 : 16) * 2 + let totalPadding: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad ? 13 : 16) * 2 + let maxScrollViewHeight = popverHeight - totalSpacing - totalPadding - 120 + self._height = min(geometry.size.height, maxScrollViewHeight) + } + } + ) } + .frame(height: _height) } } @@ -90,18 +134,19 @@ struct OptionListPickerCustomLayout: Layout { return .zero } var containerHeight = 0.0 - var currentRowX = 16.0 + var currentRowX = 0.0 + let padding = UIDevice.current.userInterfaceIdiom == .pad ? 13.0 : 16.0 for index in 0 ..< subviews.count { let subview = subviews[index] let subviewSize = subview.sizeThatFits(.unspecified) - let subviewWidth = min(subviewSize.width, containerWidth) + let subviewWidth = min(subviewSize.width, containerWidth - CGFloat(padding * 2)) if index == 0 { containerHeight += subviewSize.height } - if currentRowX + subviewWidth + 16.0 > containerWidth { + if currentRowX + subviewWidth + padding > containerWidth - CGFloat(padding * 2) { containerHeight += subviewSize.height containerHeight += 6 - currentRowX = 16.0 + currentRowX = 0.0 } currentRowX += subviewWidth + 6.0 } @@ -111,14 +156,15 @@ struct OptionListPickerCustomLayout: Layout { func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { guard let containerWidth = proposal.width else { return } var currentY: CGFloat = bounds.minY - var currentRowX = 16.0 + var currentRowX = 0.0 + let padding = UIDevice.current.userInterfaceIdiom == .pad ? 13.0 : 16.0 for subview in subviews { let subviewSize = subview.sizeThatFits(.unspecified) - let subviewWidth = min(subviewSize.width, containerWidth) - if currentRowX + subviewWidth + 16.0 > containerWidth { + let subviewWidth = min(subviewSize.width, containerWidth - CGFloat(padding * 2)) + if currentRowX + subviewWidth + padding > containerWidth - CGFloat(padding * 2) { currentY += subviewSize.height currentY += 6 - currentRowX = 16.0 + currentRowX = 0.0 subview.place(at: CGPoint(x: currentRowX, y: currentY), proposal: ProposedViewSize(width: subviewWidth, height: subviewSize.height)) } else { subview.place(at: CGPoint(x: currentRowX, y: currentY), proposal: ProposedViewSize(width: subviewWidth, height: subviewSize.height)) diff --git a/Sources/FioriSwiftUICore/Views/SearchListPickerItem+View.swift b/Sources/FioriSwiftUICore/Views/SearchListPickerItem+View.swift new file mode 100644 index 000000000..1826f2223 --- /dev/null +++ b/Sources/FioriSwiftUICore/Views/SearchListPickerItem+View.swift @@ -0,0 +1,126 @@ +import SwiftUI +import UIKit + +public extension SearchListPickerItem { + /// create a list picker which used in FilterFeedbackBarItem + /// - Parameters: + /// - value: Selected value indexs. + /// - valueOptions: The data for constructing the list picker. + /// - hint: Hint message. + /// - allowsMultipleSelection: A boolean value to indicate to allow multiple selections or not. + /// - allowsEmptySelection: A boolean value to indicate to allow empty selection or not. + /// - onTap: The closure when tap on item. + /// - selectAll: The closure when click 'Select All' button. + /// - updateSearchListPickerHeight: The closure to update the parent view. + init(value: Binding<[Int]>, valueOptions: [String] = [], hint: String? = nil, allowsMultipleSelection: Bool, allowsEmptySelection: Bool, onTap: ((_ index: Int) -> Void)? = nil, selectAll: ((_ isAll: Bool) -> Void)? = nil, updateSearchListPickerHeight: ((CGFloat) -> Void)? = nil) { + self.init(value: value, valueOptions: valueOptions, hint: hint, onTap: onTap) + + self.allowsMultipleSelection = allowsMultipleSelection + self.allowsEmptySelection = allowsEmptySelection + self.selectAll = selectAll + self.updateSearchListPickerHeight = updateSearchListPickerHeight + } +} + +extension SearchListPickerItem: View { + public var body: some View { + VStack(spacing: 0) { + if allowsMultipleSelection { + if _value.count != _valueOptions.count || allowsEmptySelection { + self.selectAllView() + } + } else if _value.count == _valueOptions.count { + self.selectAllView() + } + + Divider().edgesIgnoringSafeArea(.all) + List { + ForEach(_valueOptions.filter { _searchText.isEmpty || $0.localizedStandardContains(_searchText) }, id: \.self) { item in + let isSelected = self.isItemSelected(item) + HStack { + Text(item) + .lineLimit(1) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + .font(.fiori(forTextStyle: .body, weight: .regular)) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.preferredColor(.tintColor)) + } + } + .frame(maxWidth: .infinity) + .padding(0) + .contentShape(Rectangle()) + .onTapGesture { + guard let index = findIndex(of: item) else { + return + } + _onTap?(index) + } + } + } + .modifier(FioriIntrospectModifier { scrollView in + DispatchQueue.main.async { + let popverHeight = Screen.bounds.size.height - StatusBar.height + let totalSpacing: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad ? 8 : 16) * 2 + let totalPadding: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad ? 13 : 16) * 2 + let maxScrollViewHeight = popverHeight - totalSpacing - totalPadding - 52 - 56 - 120 + + self._height = UIDevice.current.userInterfaceIdiom != .phone ? min(scrollView.contentSize.height, 396) : min(min(scrollView.contentSize.height, maxScrollViewHeight), 396) + var isSelectAllViewShow = false + if allowsMultipleSelection { + if _value.count != _valueOptions.count || allowsEmptySelection { + isSelectAllViewShow = true + } + } else if _value.count == _valueOptions.count { + isSelectAllViewShow = true + } + updateSearchListPickerHeight?(isSelectAllViewShow ? self._height + 44 : self._height) + } + }) + .listStyle(PlainListStyle()) + .frame(maxWidth: .infinity) + .frame(minWidth: UIDevice.current.userInterfaceIdiom != .phone ? 393 : nil) + .frame(height: self._height) + .scrollContentBackground(.hidden) + .padding(0) + .searchable(text: $_searchText, placement: .automatic) + } + } + + private func selectAllView() -> some View { + HStack { + Text(NSLocalizedString("All", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "")) + .foregroundStyle(Color.preferredColor(.secondaryLabel)) + .font(.fiori(forTextStyle: .subheadline, weight: .regular)) + Spacer() + Button(action: { + selectAll?(_value.count != _valueOptions.count) + }) { + Text(_value.count == _valueOptions.count ? NSLocalizedString("Deselect All", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") : NSLocalizedString("Select All", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "")) + } + } + .padding([.leading, .trailing], UIDevice.current.userInterfaceIdiom == .pad ? 13 : 16) + .padding([.top, .bottom], 8) + } + + private func isItemSelected(_ item: String) -> Bool { + guard let index = findIndex(of: item) else { + return false + } + return _value.wrappedValue.contains(index) + } + + private func findIndex(of item: String) -> Int? { + _valueOptions.firstIndex(where: { $0 == item }) + } +} + +#Preview { + VStack { + Spacer() + SearchListPickerItem(value: Binding<[Int]>(get: { [0, 1, 2] }, set: { print($0) }), valueOptions: ["Received", "Started", "Hold", "Transfer", "Completed", "Pending Review review", "Accepted", "Rejected"], hint: nil) + .frame(width: 375) + Spacer() + } +} diff --git a/Sources/FioriSwiftUICore/Views/SortFilter/FilterFeedbackBarItem+View.swift b/Sources/FioriSwiftUICore/Views/SortFilter/FilterFeedbackBarItem+View.swift index 0031060f1..5dac4b0b4 100644 --- a/Sources/FioriSwiftUICore/Views/SortFilter/FilterFeedbackBarItem+View.swift +++ b/Sources/FioriSwiftUICore/Views/SortFilter/FilterFeedbackBarItem+View.swift @@ -77,7 +77,7 @@ struct SliderMenuItem: View { .onTapGesture { self.isSheetVisible.toggle() } - .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { + .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom)) { CancellableResettableDialogForm { SortFilterItemTitle(title: self.item.name) } cancelAction: { @@ -129,10 +129,21 @@ struct PickerMenuItem: View { } var body: some View { - if self.item.valueOptions.count > 4 { + switch self.item.displayMode { + case .automatic: + if self.item.valueOptions.count > 8 { + self.list + } else if self.item.valueOptions.count > 4, self.item.valueOptions.count <= 8 { + self.button + } else { + self.menu + } + case .filterFormCell: self.button - } else { + case .menu: self.menu + case .list: + self.list } } @@ -142,7 +153,7 @@ struct PickerMenuItem: View { .onTapGesture { self.isSheetVisible.toggle() } - .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { + .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom)) { CancellableResettableDialogForm { SortFilterItemTitle(title: self.item.name) } cancelAction: { @@ -206,6 +217,52 @@ struct PickerMenuItem: View { } } } + + @ViewBuilder + var list: some View { + FilterFeedbackBarItem(leftIcon: icon(name: self.item.icon, isVisible: true), title: self.item.label, rightIcon: Image(systemName: "chevron.down"), isSelected: self.item.isChecked) + .onTapGesture { + self.isSheetVisible.toggle() + } + .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom)) { + CancellableResettableDialogNavigationForm { + SortFilterItemTitle(title: self.item.name) + } cancelAction: { + _Action(actionText: NSLocalizedString("Cancel", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: ""), didSelectAction: { + self.item.cancel() + self.isSheetVisible.toggle() + }) + .buttonStyle(CancelButtonStyle()) + } resetAction: { + _Action(actionText: NSLocalizedString("Reset", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: ""), didSelectAction: { + self.item.reset() + }) + .buttonStyle(ResetButtonStyle()) + .disabled(self.item.isOriginal) + } applyAction: { + _Action(actionText: NSLocalizedString("Apply", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: ""), didSelectAction: { + self.item.apply() + self.onUpdate() + self.isSheetVisible.toggle() + }) + .buttonStyle(ApplyButtonStyle()) + } components: { + SearchListPickerItem(value: self.$item.workingValue, valueOptions: self.item.valueOptions, hint: nil, allowsMultipleSelection: self.item.allowsMultipleSelection, allowsEmptySelection: self.item.allowsEmptySelection) { index in + self.item.onTap(option: self.item.valueOptions[index]) + } selectAll: { isAll in + self.item.selectAll(isAll) + } updateSearchListPickerHeight: { height in + self.detentHeight = height + 52 + 56 + 70 + } + .padding(0) + Spacer() + } + .frame(maxWidth: .infinity) + .frame(minWidth: UIDevice.current.userInterfaceIdiom != .phone ? 393 : nil) + .frame(height: UIDevice.current.userInterfaceIdiom != .phone ? self.detentHeight : nil) + .presentationDetents([.large]) + } + } } struct HeightPreferenceKey: PreferenceKey { @@ -254,7 +311,7 @@ struct DateTimeMenuItem: View { .onTapGesture { self.isSheetVisible.toggle() } - .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { + .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom)) { CancellableResettableDialogForm { SortFilterItemTitle(title: self.item.name) } cancelAction: { @@ -306,8 +363,10 @@ struct DateTimeMenuItem: View { } .readHeight() .onPreferenceChange(HeightPreferenceKey.self) { height in - if let height { - self.detentHeight = height + DispatchQueue.main.async { + if let height { + self.detentHeight = height + } } } .presentationDetents([.height(self.detentHeight)]) @@ -389,7 +448,7 @@ struct FullCFGMenuItem: View { .onTapGesture { self.isSheetVisible.toggle() } - .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { + .popover(isPresented: self.$isSheetVisible, attachmentAnchor: .point(.bottom)) { SortFilterView( title: { if let title = fullCFGButton.name { diff --git a/Sources/FioriSwiftUICore/Views/SortFilter/_SortFilterCFGItemContainer.swift b/Sources/FioriSwiftUICore/Views/SortFilter/_SortFilterCFGItemContainer.swift index 9ddb7fac9..3f8fbfecb 100644 --- a/Sources/FioriSwiftUICore/Views/SortFilter/_SortFilterCFGItemContainer.swift +++ b/Sources/FioriSwiftUICore/Views/SortFilter/_SortFilterCFGItemContainer.swift @@ -108,14 +108,38 @@ extension _SortFilterCFGItemContainer: View { .foregroundColor(Color.preferredColor(.primaryLabel)) Spacer() } - OptionListPickerItem( - value: Binding<[Int]>(get: { self._items[r][c].picker.workingValue }, set: { self._items[r][c].picker.workingValue = $0 }), - valueOptions: self._items[r][c].picker.valueOptions, - itemLayout: self._items[r][c].picker.itemLayout, - onTap: { index in + switch self._items[r][c].picker.displayMode { + case .automatic: + if self._items[r][c].picker.valueOptions.count > 8 { + SearchListPickerItem( + value: Binding<[Int]>(get: { self._items[r][c].picker.workingValue }, set: { self._items[r][c].picker.workingValue = $0 }), + valueOptions: self._items[r][c].picker.valueOptions, + allowsMultipleSelection: self._items[r][c].picker.allowsMultipleSelection, + allowsEmptySelection: self._items[r][c].picker.allowsEmptySelection + ) { index in + self._items[r][c].picker.onTap(option: self._items[r][c].picker.valueOptions[index]) + } selectAll: { isAll in + self._items[r][c].picker.selectAll(isAll) + } + } else { + self.filterFormCell(row: r, column: c) + } + case .filterFormCell: + self.filterFormCell(row: r, column: c) + case .menu: + self.filterFormCell(row: r, column: c) + case .list: + SearchListPickerItem( + value: Binding<[Int]>(get: { self._items[r][c].picker.workingValue }, set: { self._items[r][c].picker.workingValue = $0 }), + valueOptions: self._items[r][c].picker.valueOptions, + allowsMultipleSelection: self._items[r][c].picker.allowsMultipleSelection, + allowsEmptySelection: self._items[r][c].picker.allowsEmptySelection + ) { index in self._items[r][c].picker.onTap(option: self._items[r][c].picker.valueOptions[index]) + } selectAll: { isAll in + self._items[r][c].picker.selectAll(isAll) } - ) + } } } @@ -196,4 +220,48 @@ extension _SortFilterCFGItemContainer: View { // .frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 375 - 13 * 2: UIScreen.main.bounds.size.width) } } + + private func filterFormCell(row r: Int, column c: Int) -> some View { + OptionListPickerItem( + value: Binding<[Int]>(get: { self._items[r][c].picker.workingValue }, set: { self._items[r][c].picker.workingValue = $0 }), + valueOptions: self._items[r][c].picker.valueOptions, + itemLayout: self._items[r][c].picker.itemLayout, + onTap: { index in + self._items[r][c].picker.onTap(option: self._items[r][c].picker.valueOptions[index]) + } + ) + } + + private func menuView(row r: Int, column c: Int) -> some View { + HStack { + Menu { + ForEach(self._items[r][c].picker.valueOptions.indices, id: \.self) { idx in + if self._items[r][c].picker.isOptionSelected(index: idx) { + Button { + self._items[r][c].picker.onTap(option: self._items[r][c].picker.valueOptions[idx]) + self._items[r][c].picker.apply() + } label: { + Label { Text(self._items[r][c].picker.valueOptions[idx]) } icon: { Image(fioriName: "fiori.accept") } + } + } else { + Button(self._items[r][c].picker.valueOptions[idx]) { + self._items[r][c].picker.onTap(option: self._items[r][c].picker.valueOptions[idx]) + self._items[r][c].picker.apply() + } + } + } + } label: { + FilterFeedbackBarItem(leftIcon: self.icon(name: self._items[r][c].picker.icon, isVisible: true), title: self._items[r][c].picker.label, isSelected: self._items[r][c].picker.isChecked) + } + } + } + + private func icon(name: String?, isVisible: Bool) -> Image? { + if isVisible { + if let name { + return Image(systemName: name) + } + } + return nil + } } diff --git a/Sources/FioriSwiftUICore/_generated/ViewModels/API/OptionListPickerItem+API.generated.swift b/Sources/FioriSwiftUICore/_generated/ViewModels/API/OptionListPickerItem+API.generated.swift index a3e3bd04b..94bc93b72 100644 --- a/Sources/FioriSwiftUICore/_generated/ViewModels/API/OptionListPickerItem+API.generated.swift +++ b/Sources/FioriSwiftUICore/_generated/ViewModels/API/OptionListPickerItem+API.generated.swift @@ -10,7 +10,7 @@ public struct OptionListPickerItem { var _hint: String? = nil var _itemLayout: OptionListPickerItemLayoutType var _onTap: ((_ index: Int) -> Void)? = nil - + @State var _height: CGFloat = 0 public init(model: OptionListPickerItemModel) { self.init(value: Binding<[Int]>(get: { model.value }, set: { model.value = $0 }), valueOptions: model.valueOptions, hint: model.hint, itemLayout: model.itemLayout, onTap: model.onTap) } diff --git a/Sources/FioriSwiftUICore/_generated/ViewModels/API/SearchListPickerItem+API.generated.swift b/Sources/FioriSwiftUICore/_generated/ViewModels/API/SearchListPickerItem+API.generated.swift new file mode 100644 index 000000000..e0aa1387e --- /dev/null +++ b/Sources/FioriSwiftUICore/_generated/ViewModels/API/SearchListPickerItem+API.generated.swift @@ -0,0 +1,29 @@ +// Generated using Sourcery 2.1.7 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +import SwiftUI + +public struct SearchListPickerItem { + @Environment(\.filterFeedbackBarStyle) var filterFeedbackBarStyle + + var _value: Binding<[Int]> + var _valueOptions: [String] + var _hint: String? = nil + var _onTap: ((_ index: Int) -> Void)? = nil + @State var _searchText: String = "" + var allowsEmptySelection: Bool = false + var selectAll: ((Bool) -> ())? = nil + @State var _height: CGFloat = 44 + var allowsMultipleSelection: Bool = false + var updateSearchListPickerHeight: ((CGFloat) -> ())? = nil + @State var _searchViewCornerRadius: CGFloat = 18 + public init(model: SearchListPickerItemModel) { + self.init(value: Binding<[Int]>(get: { model.value }, set: { model.value = $0 }), valueOptions: model.valueOptions, hint: model.hint, onTap: model.onTap) + } + + public init(value: Binding<[Int]>, valueOptions: [String] = [], hint: String? = nil, onTap: ((_ index: Int) -> Void)? = nil) { + self._value = value + self._valueOptions = valueOptions + self._hint = hint + self._onTap = onTap + } +} diff --git a/Sources/FioriSwiftUICore/_generated/ViewModels/API/SearchableListView+API.generated.swift b/Sources/FioriSwiftUICore/_generated/ViewModels/API/SearchableListView+API.generated.swift index 29c5ada4b..6a16d441d 100644 --- a/Sources/FioriSwiftUICore/_generated/ViewModels/API/SearchableListView+API.generated.swift +++ b/Sources/FioriSwiftUICore/_generated/ViewModels/API/SearchableListView+API.generated.swift @@ -12,9 +12,9 @@ public struct SearchableListView { let _cancelAction: CancelActionView let _doneAction: DoneActionView - var dataHandler: (() -> ())? = nil var isTopLevel: Bool = true var contentView: AnyView? = nil + var dataHandler: (() -> ())? = nil private var isModelInit: Bool = false private var isCancelActionNil: Bool = false diff --git a/Sources/FioriSwiftUICore/_generated/ViewModels/Boilerplate/SearchListPickerItem+View.generated.swift b/Sources/FioriSwiftUICore/_generated/ViewModels/Boilerplate/SearchListPickerItem+View.generated.swift new file mode 100644 index 000000000..65db096aa --- /dev/null +++ b/Sources/FioriSwiftUICore/_generated/ViewModels/Boilerplate/SearchListPickerItem+View.generated.swift @@ -0,0 +1,34 @@ +// Generated using Sourcery 2.1.7 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +//TODO: Copy commented code to new file: `FioriSwiftUICore/Views/SearchListPickerItem+View.swift` +//TODO: Implement SearchListPickerItem `View` body + +/// - Important: to make `@Environment` properties (e.g. `horizontalSizeClass`), internally accessible +/// to extensions, add as sourcery annotation in `FioriSwiftUICore/Models/ModelDefinitions.swift` +/// to declare a wrapped property +/// e.g.: `// sourcery: add_env_props = ["horizontalSizeClass"]` + +/* +import SwiftUI + +// FIXME: - Implement Fiori style definitions + +// FIXME: - Implement SearchListPickerItem View body + +extension SearchListPickerItem: View { + public var body: some View { + <# View body #> + } +} + +// FIXME: - Implement SearchListPickerItem specific LibraryContentProvider + +@available(iOS 14.0, macOS 11.0, *) +struct SearchListPickerItemLibraryContent: LibraryContentProvider { + @LibraryContentBuilder + var views: [LibraryItem] { + LibraryItem(SearchListPickerItem(model: LibraryPreviewData.Person.laurelosborn), + category: .control) + } +} +*/ diff --git a/Sources/FioriSwiftUICore/_generated/ViewModels/Model+Extensions/SearchListPickerItemModel+Extensions.generated.swift b/Sources/FioriSwiftUICore/_generated/ViewModels/Model+Extensions/SearchListPickerItemModel+Extensions.generated.swift new file mode 100644 index 000000000..58d6c7e66 --- /dev/null +++ b/Sources/FioriSwiftUICore/_generated/ViewModels/Model+Extensions/SearchListPickerItemModel+Extensions.generated.swift @@ -0,0 +1,9 @@ +// Generated using Sourcery 2.1.7 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +import SwiftUI + +public extension SearchListPickerItemModel { + var onTap: ((_ index: Int) -> Void)? { + return nil + } +} diff --git a/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings b/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings index 8424bd53b..1c1d50b3a 100644 --- a/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings +++ b/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings @@ -253,3 +253,12 @@ "warnings" = "warnings"; /* XBUT: banner message type desc in plural form, errors */ "errors" = "errors"; + +/* XBUT: The \"Select All\" button title on the List Picker header */ +"Select All" = "Select All"; +/* XBUT: The \"Deselect All\" button title on the List Picker header */ +"Deselect All" = "Deselect All"; +/* XBUT: Reset action of filter feedback bar, see https://experience.sap.com/fiori-design-ios/article/filter-feedback-bar/#behavior-and-interaction */ +"Reset" = "Reset"; +/* XBUT: Apply action of filter feedback bar, see https://experience.sap.com/fiori-design-ios/article/filter-feedback-bar/#behavior-and-interaction */ +"Apply" = "Apply";