From 05b5a86d1e7ed9c96c905f6776077376c6f1f0cc Mon Sep 17 00:00:00 2001 From: shengxu7 Date: Tue, 30 Jan 2024 11:21:33 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20[JIRA:=20HCPSDKFIORIUIKI?= =?UTF-8?q?T-2453]DataTable=20readonly=20support=20(#626)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DataTable/DataTableExample.swift | 45 +++--- .../DataTable/ColumnAttribute.swift | 7 +- .../DataTable/DataDateItem.swift | 17 ++- .../DataTable/DataDurationItem.swift | 17 ++- .../DataTable/DataImageItem.swift | 13 +- .../FioriSwiftUICore/DataTable/DataItem.swift | 23 ++- .../DataTable/DataListItem.swift | 21 ++- .../DataTable/DataTableItem.swift | 8 +- .../DataTable/DataTextItem.swift | 21 ++- .../DataTable/DataTimeItem.swift | 21 ++- .../DataTable/GridTableView.swift | 6 +- .../FioriSwiftUICore/DataTable/ItemView.swift | 61 +++++--- .../DataTable/LayoutData.swift | 26 +++- .../DataTable/TableRowItem.swift | 15 +- .../FioriSwiftUICore/Toast/ToastView.swift | 142 ++++++++++++++++++ .../en.lproj/FioriSwiftUICore.strings | 3 + .../FioriSwiftUICore/DataTableTests.swift | 95 ++++++++++++ 17 files changed, 472 insertions(+), 69 deletions(-) create mode 100644 Sources/FioriSwiftUICore/Toast/ToastView.swift create mode 100644 Tests/FioriSwiftUITests/FioriSwiftUICore/DataTableTests.swift diff --git a/Apps/Examples/Examples/FioriSwiftUICore/DataTable/DataTableExample.swift b/Apps/Examples/Examples/FioriSwiftUICore/DataTable/DataTableExample.swift index 322a42745..385a05140 100644 --- a/Apps/Examples/Examples/FioriSwiftUICore/DataTable/DataTableExample.swift +++ b/Apps/Examples/Examples/FioriSwiftUICore/DataTable/DataTableExample.swift @@ -132,16 +132,16 @@ struct DataTableExample: View { Section(header: Text("Sticky header/column")) { NavigationLink("Not sticky header & column, baseline alignment", - destination: DataTableExampleView(model: TestRowData.generateData(row: 10, column: 5, containLeadingAccessory: false, containTrailingAccessory: false, isHeaderSticky: false, isFirstColumnSticky: false, isPinchZoomEnable: true, showListView: false))) + destination: DataTableExampleView(model: TestRowData.generateData(row: 20, column: 15, containLeadingAccessory: false, containTrailingAccessory: false, isHeaderSticky: false, isFirstColumnSticky: false, isPinchZoomEnable: true, showListView: false))) - NavigationLink("Sticky header", - destination: DataTableExampleView(model: TestRowData.generateData(row: 10, column: 5, containLeadingAccessory: false, containTrailingAccessory: true, isHeaderSticky: true, isFirstColumnSticky: false, isPinchZoomEnable: true, showListView: false))) + NavigationLink("Sticky header with readonly rows", + destination: DataTableExampleView(model: TestRowData.generateData(row: 20, column: 15, containLeadingAccessory: false, containTrailingAccessory: true, isHeaderSticky: true, isFirstColumnSticky: false, isPinchZoomEnable: true, showListView: false, rowIndexesReadonly: [1, 3]))) - NavigationLink("Sticky column", - destination: DataTableExampleView(model: TestRowData.generateData(row: 10, column: 5, containLeadingAccessory: true, containTrailingAccessory: false, isHeaderSticky: false, isFirstColumnSticky: true, isPinchZoomEnable: true, showListView: false))) + NavigationLink("Sticky column with readonly columns", + destination: DataTableExampleView(model: TestRowData.generateData(row: 20, column: 15, containLeadingAccessory: true, containTrailingAccessory: false, isHeaderSticky: false, isFirstColumnSticky: true, isPinchZoomEnable: true, showListView: false, columnIndexesReadonly: [0, 2, 4]))) - NavigationLink("Sticky header & column", - destination: DataTableExampleView(model: TestRowData.generateData(row: 10, column: 5, containLeadingAccessory: false, containTrailingAccessory: false, isHeaderSticky: true, isFirstColumnSticky: true, isPinchZoomEnable: true, showListView: false, isFixedWidth: true))) + NavigationLink("Sticky header & column with readonly cells", + destination: DataTableExampleView(model: TestRowData.generateData(row: 20, column: 15, containLeadingAccessory: false, containTrailingAccessory: false, isHeaderSticky: true, isFirstColumnSticky: true, isPinchZoomEnable: true, showListView: false, isFixedWidth: false, isReadonlyForRandomCell: true))) } Section(header: Text("Variant rows/columns")) { @@ -155,7 +155,7 @@ struct DataTableExample: View { destination: DataTableExampleView(model: TestRowData.generateData(row: 5, column: 3, isHeaderSticky: false, isFirstColumnSticky: false, isPinchZoomEnable: true, showListView: false))) NavigationLink("20 rows 12 columns", - destination: DataTableExampleView(model: TestRowData.generateData(row: 20, column: 12, containLeadingAccessory: false, containTrailingAccessory: false, containIndex: true, isHeaderSticky: true, isFirstColumnSticky: true, isPinchZoomEnable: true, showListView: false), addRowsDuringScrolling: true)) + destination: DataTableExampleView(model: TestRowData.generateData(row: 20, column: 12, containLeadingAccessory: false, containTrailingAccessory: false, containIndex: true, isHeaderSticky: true, isFirstColumnSticky: true, isPinchZoomEnable: true, showListView: false, isReadonlyForRandomCell: true), addRowsDuringScrolling: true)) NavigationLink("300 rows 60 columns", destination: DataTableExampleView(model: TestRowData.generateData(row: 300, column: 60, containIndex: true, isHeaderSticky: true, isFirstColumnSticky: true, isPinchZoomEnable: true, showListView: false))) @@ -205,7 +205,8 @@ let row3WithAlignment = TableRowItem(data: [DataImageItem(Image("wheel")), DataT let row1WithDate = TableRowItem(data: [DataTextItem("Hello", Font.headline, Color.orange), DataImageItem(Image("wheel")), DataDateItem(Date(timeIntervalSince1970: 1), Font.largeTitle, Color.preferredColor(.chart2)), DataTimeItem(Date(timeIntervalSince1970: 1), Font.headline, Color.purple), DataDurationItem(3000, Font.footnote, Color.preferredColor(.secondaryLabel)), DataListItem("San Jose")]) let row2WithDate = TableRowItem(data: [DataImageItem(Image("wheel")), DataTextItem("World"), DataDateItem(Date(timeIntervalSinceReferenceDate: 1), Font.title2), DataTimeItem(Date(timeIntervalSinceReferenceDate: 1000)), DataDurationItem(23000), DataListItem("New York", Font.headline)]) -let row3WithDate = TableRowItem(data: [DataTextItem("Leading", Font.largeTitle, Color.purple), DataImageItem(Image("wheel")), DataDateItem(Date(), Font.headline), DataTimeItem(Date()), DataDurationItem(12002), DataListItem("Los Angeles", Font.title3, Color.pink)]) +let row3WithDate = TableRowItem(data: [DataTextItem("Leading", Font.largeTitle, Color.purple, isReadonly: true), + DataImageItem(Image("wheel")), DataDateItem(Date(), Font.headline), DataTimeItem(Date()), DataDurationItem(12002), DataListItem("Los Angeles", Font.title3, Color.pink)]) let threeRowThreeColumn = TableModel(headerData: nil, rowData: [row1WithAlignment, row2WithAlignment, row3WithAlignment], @@ -249,7 +250,7 @@ public enum TestRowData { static let colors = [Color.purple, Color.green, Color.indigo, Color.orange, Color.preferredColor(.primaryLabel)] static let cities = ["Aberdeen", "Anchorage", "Arvada", "Arvada", "Bakersfield", "Birmingham", "Davenport", "Duluth", "Elkhart", "Hollywood", "Indianapolis", "Knoxville", "Laredo", "San Jose", "New York", "Los Angeles", "Las Vegas", "Tokyo", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "Dallas", "Rancho Cucamonga", "Vancouver"] - static func generateRowData(numOfColumns: Int, rowIndex: Int, containLeadingAccessory: Bool = true, containTrailingAccessory: Bool = true, containIndex: Bool = false, newRowHint: Bool = false) -> TableRowItem { + static func generateRowData(numOfColumns: Int, rowIndex: Int, containLeadingAccessory: Bool = true, containTrailingAccessory: Bool = true, containIndex: Bool = false, newRowHint: Bool = false, isReadonly: Bool = false, isReadonlyForRandomCell: Bool = false) -> TableRowItem { var data: [DataItem] = [] for i in 0 ..< numOfColumns { let dataType = i % DataItemType.allCases.count @@ -272,21 +273,21 @@ public enum TestRowData { textString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus mattis tristique pretium." } let finalText = containIndex ? "(\(rowIndex), \(i)): " + textString : textString - let textItem = DataTextItem(newRowHint ? "New column" : finalText, font, color) + let textItem = DataTextItem(newRowHint ? "New column" : finalText, font, color, isReadonly: isReadonlyForRandomCell ? ((Int.random(in: 0 ... 8) + rowIndex + i) % 5 == 0 ? true : nil) : nil) data.append(textItem) case 2: let ti = rowIndex * 3600 * 24 + (i + 4) * 3600 - let dateItem = DataDateItem(Date(timeIntervalSinceReferenceDate: TimeInterval(ti)), font, color) + let dateItem = DataDateItem(Date(timeIntervalSinceReferenceDate: TimeInterval(ti)), font, color, isReadonly: isReadonlyForRandomCell ? ((Int.random(in: 0 ... 8) + rowIndex + i) % 5 == 0 ? true : nil) : nil) data.append(dateItem) case 3: let ti = rowIndex * 3600 + i * 60 - let timeItem = DataTimeItem(Date(timeIntervalSinceReferenceDate: TimeInterval(ti)), font, color) + let timeItem = DataTimeItem(Date(timeIntervalSinceReferenceDate: TimeInterval(ti)), font, color, isReadonly: isReadonlyForRandomCell ? ((Int.random(in: 0 ... 8) + rowIndex + i) % 5 == 0 ? true : nil) : nil) data.append(timeItem) case 4: - let durationItem = DataDurationItem(TimeInterval(3600 + rowIndex * 600 + i), font, color) + let durationItem = DataDurationItem(TimeInterval(3600 + rowIndex * 600 + i), font, color, isReadonly: isReadonlyForRandomCell ? ((Int.random(in: 0 ... 8) + rowIndex + i) % 5 == 0 ? true : nil) : nil) data.append(durationItem) default: - let listItem = DataListItem(cities[(rowIndex + i) % self.cities.count], font, color) + let listItem = DataListItem(cities[(rowIndex + i) % self.cities.count], font, color, isReadonly: isReadonlyForRandomCell ? ((Int.random(in: 0 ... 8) + rowIndex + i) % 5 == 0 ? true : nil) : nil) data.append(listItem) } } @@ -296,7 +297,7 @@ public enum TestRowData { print("trailing accessory tapped: \(rowIndex) tapped") })) - let output = TableRowItem(leadingAccessories: containLeadingAccessory ? lAccessories : [], trailingAccessory: containTrailingAccessory ? tAccessory : nil, data: data) + let output = TableRowItem(leadingAccessories: containLeadingAccessory ? lAccessories : [], trailingAccessory: containTrailingAccessory ? tAccessory : nil, data: data, isReadonly: isReadonly ? true : nil) return output } @@ -310,27 +311,27 @@ public enum TestRowData { return TableRowItem(data: data) } - static func generateColumnAttributes(column: Int, isFixedWidth: Bool = false) -> [ColumnAttribute] { + static func generateColumnAttributes(column: Int, isFixedWidth: Bool = false, columnIndexesReadonly: [Int] = []) -> [ColumnAttribute] { var output: [ColumnAttribute] = [] - for _ in 0 ..< column { - let att = ColumnAttribute(textAlignment: .leading, width: isFixedWidth ? .fixed(200) : .flexible) + for i in 0 ..< column { + let att = ColumnAttribute(textAlignment: .leading, width: isFixedWidth ? .fixed(200) : .flexible, isReadonly: columnIndexesReadonly.contains(i) ? true : nil) output.append(att) } return output } - static func generateData(row: Int, column: Int, containLeadingAccessory: Bool = true, containTrailingAccessory: Bool = true, containIndex: Bool = false, isHeaderSticky: Bool = true, isFirstColumnSticky: Bool = true, isPinchZoomEnable: Bool = true, showListView: Bool = false, isFixedWidth: Bool = false) -> TableModel { + static func generateData(row: Int, column: Int, containLeadingAccessory: Bool = true, containTrailingAccessory: Bool = true, containIndex: Bool = false, isHeaderSticky: Bool = true, isFirstColumnSticky: Bool = true, isPinchZoomEnable: Bool = true, showListView: Bool = false, isFixedWidth: Bool = false, rowIndexesReadonly: [Int] = [], columnIndexesReadonly: [Int] = [], isReadonlyForRandomCell: Bool = false) -> TableModel { var res: [TableRowItem] = [] var titles: [DataTextItem] = [] for k in 0 ..< column { titles.append(DataTextItem(self.types[k % self.types.count])) } for i in 0 ..< row { - res.append(self.generateRowData(numOfColumns: column, rowIndex: i, containLeadingAccessory: containLeadingAccessory, containTrailingAccessory: containTrailingAccessory, containIndex: containIndex)) + res.append(self.generateRowData(numOfColumns: column, rowIndex: i, containLeadingAccessory: containLeadingAccessory, containTrailingAccessory: containTrailingAccessory, containIndex: containIndex, isReadonly: rowIndexesReadonly.contains(i) ? true : false, isReadonlyForRandomCell: isReadonlyForRandomCell)) } let header = TableRowItem(data: titles) let model = TableModel(headerData: header, rowData: res, isHeaderSticky: isHeaderSticky, isFirstColumnSticky: isFirstColumnSticky, isPinchZoomEnable: isPinchZoomEnable, showListView: showListView) - model.columnAttributes = self.generateColumnAttributes(column: column, isFixedWidth: isFixedWidth) + model.columnAttributes = self.generateColumnAttributes(column: column, isFixedWidth: isFixedWidth, columnIndexesReadonly: columnIndexesReadonly) model.didSelectRowAt = { rowIndex in print("Tapped row \(rowIndex)") } diff --git a/Sources/FioriSwiftUICore/DataTable/ColumnAttribute.swift b/Sources/FioriSwiftUICore/DataTable/ColumnAttribute.swift index cb493f1db..7de5909c4 100644 --- a/Sources/FioriSwiftUICore/DataTable/ColumnAttribute.swift +++ b/Sources/FioriSwiftUICore/DataTable/ColumnAttribute.swift @@ -19,7 +19,8 @@ public struct ColumnAttribute { public var textAlignment: TextAlignment = .leading /// Setting the width for each column. public var width: Width = .flexible - + /// Read-only property for all cells in this column. If a cell's `isReadonly` within this column is set, then that value is used instead. `nil` means it is `false`. + public var isReadonly: Bool? /// used by date or time column public var dateFormatter: DateFormatter? @@ -70,8 +71,10 @@ public struct ColumnAttribute { /// - Parameters: /// - textAlignment: Text alignment in each column. /// - width: Setting the width for each column. - public init(textAlignment: TextAlignment = .leading, width: Width = .flexible) { + /// - isReadonly: Is the column read-only or not for the inline editing mode. + public init(textAlignment: TextAlignment = .leading, width: Width = .flexible, isReadonly: Bool? = nil) { self.textAlignment = textAlignment self.width = width + self.isReadonly = isReadonly } } diff --git a/Sources/FioriSwiftUICore/DataTable/DataDateItem.swift b/Sources/FioriSwiftUICore/DataTable/DataDateItem.swift index 2d1c44286..00c18c77c 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataDateItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataDateItem.swift @@ -26,6 +26,15 @@ public struct DataDateItem: DataItemTextComponent, CheckBinding, Equatable { /// Binding rule. public var binding: ObjectViewProperty.Text? + /** + Is the cell read-only or not for the inline editing mode. `nil` means it is `false`. + A cell's `isReadonly` is determined by the value of itself, the row and the column. + If only one of these three value is set then that value is used. + If two or three values are set, then the higher priority of value is used. + The order of priority from high to low is cell, row and column. + */ + public var isReadonly: Bool? + var hasBinding: Bool { self.binding != nil } @@ -37,12 +46,14 @@ public struct DataDateItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(_ date: Date, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not in inline editing mode. + public init(_ date: Date, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.date = date self.font = font self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } /// Public initializer for `DataTextItem` @@ -52,12 +63,14 @@ public struct DataDateItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(date: Date, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(date: Date, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.date = date self.uifont = uifont self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } func string(for columnAttribute: ColumnAttribute) -> String { diff --git a/Sources/FioriSwiftUICore/DataTable/DataDurationItem.swift b/Sources/FioriSwiftUICore/DataTable/DataDurationItem.swift index 21d3c6f11..9174a2b4c 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataDurationItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataDurationItem.swift @@ -30,6 +30,15 @@ public struct DataDurationItem: DataItemTextComponent, CheckBinding, Equatable { self.binding != nil } + /** + Is the cell read-only or not for the inline editing mode. `nil` means it is `false`. + A cell's `isReadonly` is determined by the value of itself, the row and the column. + If only one of these three value is set then that value is used. + If two or three values are set, then the higher priority of value is used. + The order of priority from high to low is cell, row and column. + */ + public var isReadonly: Bool? + /// Public initializer for `DataTextItem` /// - Parameters: /// - duration: duration for the item. @@ -37,12 +46,14 @@ public struct DataDurationItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(_ duration: TimeInterval, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not in inline editing mode. + public init(_ duration: TimeInterval, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.duration = duration self.font = font self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } /// Public initializer for `DataTextItem` @@ -52,12 +63,14 @@ public struct DataDurationItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(duration: TimeInterval, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(duration: TimeInterval, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.duration = duration self.uifont = uifont self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } func string(for columnAttribute: ColumnAttribute) -> String { diff --git a/Sources/FioriSwiftUICore/DataTable/DataImageItem.swift b/Sources/FioriSwiftUICore/DataTable/DataImageItem.swift index 4b06cf4c2..5e37aeb99 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataImageItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataImageItem.swift @@ -12,6 +12,11 @@ public struct DataImageItem: DataItemImageComponent, CheckBinding, Equatable { /// Tint color for image. public var tintColor: Color? + /** + Is the cell read-only or not for the inline editing mode. `DataImageItem` ignores this property since it doesn't support inline editing yet. + */ + public var isReadonly: Bool? + var hasBinding: Bool { self.binding != nil } @@ -21,10 +26,12 @@ public struct DataImageItem: DataItemImageComponent, CheckBinding, Equatable { /// - image: Image for item. /// - tintColor: Tint color for image. /// - binding: Mapping rule. - public init(_ image: Image, _ tintColor: Color? = nil, _ binding: ObjectViewProperty.Image? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(_ image: Image, _ tintColor: Color? = nil, _ binding: ObjectViewProperty.Image? = nil, isReadonly: Bool? = nil) { self.image = image.resizable() self.tintColor = tintColor self.binding = binding + self.isReadonly = isReadonly } /// check equality @@ -37,6 +44,10 @@ public struct DataImageItem: DataItemImageComponent, CheckBinding, Equatable { return false } + if lhs.isReadonly != rhs.isReadonly { + return false + } + return true } } diff --git a/Sources/FioriSwiftUICore/DataTable/DataItem.swift b/Sources/FioriSwiftUICore/DataTable/DataItem.swift index ccb7d0ee9..6676872da 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataItem.swift @@ -26,17 +26,26 @@ public protocol DataItem { /// Returns the `DataItemType` enum value for the item. var type: DataItemType { get } + /** + Is the cell read-only or not for the inline editing mode. `nil` means it is `false`. + A cell's `isReadonly` is determined by the value of itself, the row and the column. + If only one of these three value is set then that value is used. + If two or three values are set, then the higher priority of value is used. + The order of priority from high to low is cell, row and column. + */ + var isReadonly: Bool? { get set } + /// conver itself to a SwiftUI View func toView() -> AnyView } // swiftlint:disable function_parameter_count protocol DataTableItemConvertion { - func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment, isHeader: Bool, isValid: Bool) -> DataTableItem? + func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment, isHeader: Bool, isValid: Bool, isReadonly: Bool) -> DataTableItem? } extension DataTableItemConvertion { - func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment, isHeader: Bool, isValid: Bool) -> DataTableItem? { + func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment, isHeader: Bool, isValid: Bool, isReadonly: Bool) -> DataTableItem? { nil } } @@ -67,7 +76,7 @@ protocol DataItemTextComponent: DataItem, DataTableItemConvertion { } extension DataItemTextComponent { - func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment = .leading, isHeader: Bool = false, isValid: Bool = true) -> DataTableItem? { + func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment = .leading, isHeader: Bool = false, isValid: Bool = true, isReadonly: Bool = false) -> DataTableItem? { let title = self.text let uifont = self.finalUIFont(isHeader) let firstBaselineHeight = uifont.lineHeight + uifont.descender @@ -99,7 +108,8 @@ extension DataItemTextComponent { size: size, textAlignment: textAlignment, lineLimit: self.lineLimit, - isValid: isValid) + isValid: isValid, + isReadonly: isReadonly) if let item = self as? DataDateItem { dataItem.date = item.date @@ -151,7 +161,7 @@ protocol DataItemImageComponent: DataItem, DataTableItemConvertion { } extension DataItemImageComponent { - func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment = .leading, isHeader: Bool = false, isValid: Bool = true) -> DataTableItem? { + func convertToDataTableItem(rowIndex: Int, columnIndex: Int, contentWidth: CGFloat, textAlignment: TextAlignment = .leading, isHeader: Bool = false, isValid: Bool = true, isReadonly: Bool) -> DataTableItem? { guard let item = (self as? DataImageItem) else { return nil } @@ -165,7 +175,8 @@ extension DataItemImageComponent { foregroundColor: item.tintColor, size: CGSize(width: TableViewLayout.imageSize, height: TableViewLayout.imageSize), textAlignment: textAlignment, - isValid: isValid) + isValid: isValid, + isReadonly: isReadonly) } /// conver itself to a SwiftUI View diff --git a/Sources/FioriSwiftUICore/DataTable/DataListItem.swift b/Sources/FioriSwiftUICore/DataTable/DataListItem.swift index 01a8e6c67..87666c12b 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataListItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataListItem.swift @@ -22,6 +22,15 @@ public struct DataListItem: DataItemTextComponent, CheckBinding, Equatable { /// Binding rule. public var binding: ObjectViewProperty.Text? + /** + Is the cell read-only or not for the inline editing mode. `nil` means it is `false`. + A cell's `isReadonly` is determined by the value of itself, the row and the column. + If only one of these three value is set then that value is used. + If two or three values are set, then the higher priority of value is used. + The order of priority from high to low is cell, row and column. + */ + public var isReadonly: Bool? + var hasBinding: Bool { self.binding != nil } @@ -33,12 +42,14 @@ public struct DataListItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(_ text: String, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(_ text: String, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.text = text self.font = font self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } /// Public initializer for `DataTextItem` @@ -48,12 +59,14 @@ public struct DataListItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(text: String, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(text: String, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.text = text self.uifont = uifont self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } func string(for columnAttribute: ColumnAttribute) -> String { @@ -82,6 +95,10 @@ public struct DataListItem: DataItemTextComponent, CheckBinding, Equatable { return false } + if lhs.isReadonly != rhs.isReadonly { + return false + } + return true } } diff --git a/Sources/FioriSwiftUICore/DataTable/DataTableItem.swift b/Sources/FioriSwiftUICore/DataTable/DataTableItem.swift index e0a816db5..741041c5c 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataTableItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataTableItem.swift @@ -57,6 +57,9 @@ struct DataTableItem: Identifiable, Hashable { // cache the selected index for `DataListItem` var selectedIndex: Int? + /// Is the cell read-only or not for the inline editing mode. + var isReadonly: Bool = false + init(type: DataItemType, rowIndex: Int, columnIndex: Int, @@ -71,7 +74,8 @@ struct DataTableItem: Identifiable, Hashable { offset: CGPoint = .zero, textAlignment: TextAlignment = .leading, lineLimit: Int? = nil, - isValid: Bool) + isValid: Bool, + isReadonly: Bool = false) { self.type = type self.rowIndex = rowIndex @@ -88,6 +92,7 @@ struct DataTableItem: Identifiable, Hashable { self.textAlignment = textAlignment self.lineLimit = lineLimit self.isValid = isValid + self.isReadonly = isReadonly } mutating func x(_ x: CGFloat) { @@ -150,5 +155,6 @@ struct DataTableItem: Identifiable, Hashable { hasher.combine(self.size.width) hasher.combine(self.size.height) hasher.combine(self.isValid) + hasher.combine(self.isReadonly) } } diff --git a/Sources/FioriSwiftUICore/DataTable/DataTextItem.swift b/Sources/FioriSwiftUICore/DataTable/DataTextItem.swift index 893df7ec5..3322c99be 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataTextItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataTextItem.swift @@ -23,6 +23,15 @@ public struct DataTextItem: DataItemTextComponent, CheckBinding, Equatable { /// Binding rule. public var binding: ObjectViewProperty.Text? + /** + Is the cell read-only or not for the inline editing mode. `nil` means it is `false`. + A cell's `isReadonly` is determined by the value of itself, the row and the column. + If only one of these three value is set then that value is used. + If two or three values are set, then the higher priority of value is used. + The order of priority from high to low is cell, row and column. + */ + public var isReadonly: Bool? + var hasBinding: Bool { self.binding != nil } @@ -34,12 +43,14 @@ public struct DataTextItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(_ text: String, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(_ text: String, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.text = text self.font = font self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } /// Public initializer for `DataTextItem` @@ -49,12 +60,14 @@ public struct DataTextItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(text: String, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(text: String, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.text = text self.uifont = uifont self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } func string(for columnAttribute: ColumnAttribute) -> String { @@ -83,6 +96,10 @@ public struct DataTextItem: DataItemTextComponent, CheckBinding, Equatable { return false } + if lhs.isReadonly != rhs.isReadonly { + return false + } + return true } } diff --git a/Sources/FioriSwiftUICore/DataTable/DataTimeItem.swift b/Sources/FioriSwiftUICore/DataTable/DataTimeItem.swift index 270c45f73..ca5779c3f 100644 --- a/Sources/FioriSwiftUICore/DataTable/DataTimeItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/DataTimeItem.swift @@ -26,6 +26,15 @@ public struct DataTimeItem: DataItemTextComponent, CheckBinding, Equatable { /// Binding rule. public var binding: ObjectViewProperty.Text? + /** + Is the cell read-only or not for the inline editing mode. `nil` means it is `false`. + A cell's `isReadonly` is determined by the value of itself, the row and the column. + If only one of these three value is set then that value is used. + If two or three values are set, then the higher priority of value is used. + The order of priority from high to low is cell, row and column. + */ + public var isReadonly: Bool? + var hasBinding: Bool { self.binding != nil } @@ -37,12 +46,14 @@ public struct DataTimeItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(_ date: Date, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(_ date: Date, _ font: Font? = nil, _ textColor: Color? = nil, _ binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.date = date self.font = font self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } /// Public initializer for `DataTextItem` @@ -52,12 +63,14 @@ public struct DataTimeItem: DataItemTextComponent, CheckBinding, Equatable { /// - textColor: Foreground color for text Item. /// - binding: Binding rule. /// - lineLimit: Line limit for item. - public init(date: Date, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil) { + /// - isReadonly: Is the cell read-only or not for the inline editing mode. + public init(date: Date, uifont: UIFont? = nil, textColor: Color? = nil, binding: ObjectViewProperty.Text? = nil, lineLimit: Int? = nil, isReadonly: Bool? = nil) { self.date = date self.uifont = uifont self.textColor = textColor self.binding = binding self.lineLimit = lineLimit + self.isReadonly = isReadonly } func string(for columnAttribute: ColumnAttribute) -> String { @@ -87,6 +100,10 @@ public struct DataTimeItem: DataItemTextComponent, CheckBinding, Equatable { return false } + if lhs.isReadonly != rhs.isReadonly { + return false + } + return true } } diff --git a/Sources/FioriSwiftUICore/DataTable/GridTableView.swift b/Sources/FioriSwiftUICore/DataTable/GridTableView.swift index 2e1f1424e..9078a9ef1 100644 --- a/Sources/FioriSwiftUICore/DataTable/GridTableView.swift +++ b/Sources/FioriSwiftUICore/DataTable/GridTableView.swift @@ -401,6 +401,7 @@ struct InternalGridTableView: View { /// observe this to make DataListItem refresh to show/hide the chevron icon when it enters in/out of the inline edit mode @ObservedObject var model: TableModel @State var showBanner: Bool = true + @State var toast: Toast? = nil init(layoutManager: TableLayoutManager) { self.layoutManager = layoutManager @@ -421,9 +422,10 @@ struct InternalGridTableView: View { ZStack(alignment: .top) { self.makeBody(size) .banner(isPresented: self.$showBanner, data: BannerData(title: self.layoutManager.isValid.1 ?? "")) + .toast(toast: $toast) // show the focused textfield or other type of inline editing view - if let cellIndex = layoutManager.currentCell, let ld = layoutManager.layoutData, layoutManager.model.editMode == .inline { + if let cellIndex = layoutManager.currentCell, let ld = layoutManager.layoutData, layoutManager.model.editMode == .inline, !ld.allDataItems[cellIndex.0][cellIndex.1].isReadonly { if ld.allDataItems[cellIndex.0][cellIndex.1].type == .text { InlineEditingView(layoutManager: self.layoutManager, layoutData: ld, showBanner: self.$showBanner) .id("\(cellIndex.0), \(cellIndex.1)") @@ -542,7 +544,7 @@ struct InternalGridTableView: View { let x: CGFloat = (leadingAccessoryViewWidth + currentItem.pos.x) * tmpScaleX - offsetX // cell - ItemView(rowIndex: rowIndex, columnIndex: columnIndex, layoutManager: self.layoutManager, layoutData: layoutData, showBanner: self.$showBanner) + ItemView(rowIndex: rowIndex, columnIndex: columnIndex, layoutManager: self.layoutManager, layoutData: layoutData, showBanner: self.$showBanner, showToast: $toast) .id(self.itemViewId(rowIndex: rowIndex, columnIndex: columnIndex)) .position(x: x, y: y) .accessibilityElement(children: .ignore) diff --git a/Sources/FioriSwiftUICore/DataTable/ItemView.swift b/Sources/FioriSwiftUICore/DataTable/ItemView.swift index 0d888f4c9..65eb078da 100644 --- a/Sources/FioriSwiftUICore/DataTable/ItemView.swift +++ b/Sources/FioriSwiftUICore/DataTable/ItemView.swift @@ -169,7 +169,6 @@ struct FocusedEditingView: View { Text(self.editingText) .font(font) .foregroundColor(foregroundColor) - .background(Color.preferredColor(.tintColor).opacity(0.2)) .lineLimit(dataItem.lineLimit) .multilineTextAlignment(dataItem.textAlignment) .frame(width: contentWidth - 15, alignment: dataItem.textAlignment.toTextFrameAlignment()) @@ -190,7 +189,6 @@ struct FocusedEditingView: View { Text(self.editingText) .font(font) .foregroundColor(self.isValid.0 ? foregroundColor : Color.preferredColor(.negativeLabel)) - .background((self.checkIsValid() ? Color.preferredColor(.tintColor) : Color.preferredColor(.negativeLabel)).opacity(self.colorScheme == .light ? 0.1 : 0.2)) .lineLimit(dataItem.lineLimit) .multilineTextAlignment(dataItem.textAlignment) .frame(width: contentWidth, alignment: dataItem.textAlignment.toTextFrameAlignment()) @@ -382,17 +380,19 @@ struct ItemView: View { let rowIndex: Int let columnIndex: Int @Binding var showBanner: Bool + @Binding var toast: Toast? - init(rowIndex: Int, columnIndex: Int, layoutManager: TableLayoutManager, layoutData: LayoutData, showBanner: Binding) { + init(rowIndex: Int, columnIndex: Int, layoutManager: TableLayoutManager, layoutData: LayoutData, showBanner: Binding, showToast: Binding) { self.layoutManager = layoutManager self.layoutData = layoutData self.rowIndex = rowIndex self.columnIndex = columnIndex self._showBanner = showBanner + self._toast = showToast } var body: some View { - if let currentCell = layoutManager.currentCell, currentCell.0 == rowIndex, currentCell.1 == columnIndex { + if let currentCell = layoutManager.currentCell, currentCell.0 == rowIndex, currentCell.1 == columnIndex, !layoutData.allDataItems[rowIndex][columnIndex].isReadonly { EmptyView() } else { self.makeBody(layoutData: self.layoutData) @@ -437,6 +437,22 @@ struct ItemView: View { return } + if dataItem.isReadonly && dataItem.type != .image { + let message = NSLocalizedString("Tapped cell is read-only.", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + toast = Toast(message: message) + if self.layoutManager.currentCell != nil { + self.layoutManager.currentCell = nil + } + if self.layoutManager.isValid.0 { + self.layoutManager.isValid = (false, nil) + } + if self.showBanner { + self.showBanner = false + } + + return + } + self.layoutManager.currentCell = (self.rowIndex, self.columnIndex) self.layoutManager.isValid = self.layoutManager.checkIsValid(for: layoutData.allDataItems[self.rowIndex][self.columnIndex]) self.showBanner = !self.layoutManager.isValid.0 @@ -476,7 +492,6 @@ struct ItemView: View { } } .frame(width: cellWidth, height: cellHeight) - .background(self.backgroundColorForSelectionState()) .background(self.backgroundColorForCell()) .contentShape(Rectangle()) .gesture(tapGesture) @@ -526,7 +541,8 @@ struct ItemView: View { } } - func backgroundColorForSelectionState() -> Color { + func backgroundColorForCell() -> Color { + let dataItem = self.layoutData.allDataItems[self.rowIndex][self.columnIndex] let isHeader: Bool = self.rowIndex == 0 && self.layoutManager.model.hasHeader let selectionIndex: Int = self.rowIndex - (self.layoutManager.model.hasHeader ? 1 : 0) let isSelected = self.layoutManager.model.editMode == .select && self.layoutManager.selectedIndexes.contains(selectionIndex) @@ -538,23 +554,26 @@ struct ItemView: View { isStickyCell = true } - if isStickyCell, isSelected { - return Color.preferredColor(.informationBackground) + /// Background color for cells in the sticky header and column should not be clear + if isStickyCell { + /// Background color for the selection mode + if isSelected { + return Color.preferredColor(.informationBackground) + } else if self.layoutManager.model.editMode == .inline, !isHeader, dataItem.isReadonly, dataItem.type != .image { + /// Read-only background color + return Color.preferredColor(.tertiaryFill) + } else { + /// Regular background color + return self.layoutManager.model.backgroundColor + } } else { - return Color.clear - } - } - - func backgroundColorForCell() -> Color { - let isHeader: Bool = self.rowIndex == 0 && self.layoutManager.model.hasHeader - var isStickyCell = false - if isHeader, self.layoutManager.model.isHeaderSticky { - isStickyCell = true - } else if self.layoutManager.model.isFirstColumnSticky, self.columnIndex == 0 { - isStickyCell = true + /// Read-only background color for these cells only + if dataItem.isReadonly && dataItem.type != .image && !isHeader && self.layoutManager.model.editMode == .inline { + return Color.preferredColor(.tertiaryFill) + } else { + return Color.clear + } } - - return isStickyCell ? self.layoutManager.model.backgroundColor : Color.clear } } diff --git a/Sources/FioriSwiftUICore/DataTable/LayoutData.swift b/Sources/FioriSwiftUICore/DataTable/LayoutData.swift index 1f3eb37da..4b33f1bb4 100644 --- a/Sources/FioriSwiftUICore/DataTable/LayoutData.swift +++ b/Sources/FioriSwiftUICore/DataTable/LayoutData.swift @@ -16,7 +16,7 @@ class LayoutData { // custom header cell's padding; if set it overwrites default value var headerCellPadding: EdgeInsets? - // custom header cell's padding; if set it overwrites default value + // custom data cell's padding; if set it overwrites default value var dataCellPadding: EdgeInsets? var minRowHeight: CGFloat = 48 @@ -229,7 +229,8 @@ class LayoutData { self.numOfErrors += 1 } - if let currentItem = dataInEachRow[i] as? DataTableItemConvertion, let item = currentItem.convertToDataTableItem(rowIndex: index, columnIndex: i, contentWidth: contentWidth, textAlignment: textAlignment, isHeader: isHeader, isValid: validState.0) { + let finalReadonly = self.isReadonlyForCell(rowIsReadonly: self.rowData[index].isReadonly, columnIsReadonly: columnAttribute.isReadonly, cellIsReadonly: dataInEachRow[i].isReadonly) + if let currentItem = dataInEachRow[i] as? DataTableItemConvertion, let item = currentItem.convertToDataTableItem(rowIndex: index, columnIndex: i, contentWidth: contentWidth, textAlignment: textAlignment, isHeader: isHeader, isValid: validState.0, isReadonly: finalReadonly) { res.append(item) if let uifont = item.uifont { @@ -242,6 +243,27 @@ class LayoutData { return (res, maxFirstBaselineHeight) } + /// Determine the cell's `isReadonly` from `isReadonly`of the row, column and cell + func isReadonlyForCell(rowIsReadonly: Bool?, columnIsReadonly: Bool?, cellIsReadonly: Bool?) -> Bool { + /// cellIsReadonly is the highest priority + if let value = cellIsReadonly { + return value + } + + /// rowIsReadonly is the 2nd highest priority + if let value = rowIsReadonly { + return value + } + + /// columnIsReadonly is the lowest priority + if let value = columnIsReadonly { + return value + } + + /// If none of them is set, return `false` + return false + } + func getTrailingAccessoryViewWidth() -> CGFloat { var width: CGFloat = 0 width = self.rowData.reduce(0) { partialResult, row in diff --git a/Sources/FioriSwiftUICore/DataTable/TableRowItem.swift b/Sources/FioriSwiftUICore/DataTable/TableRowItem.swift index b3fd94338..4743ad09d 100644 --- a/Sources/FioriSwiftUICore/DataTable/TableRowItem.swift +++ b/Sources/FioriSwiftUICore/DataTable/TableRowItem.swift @@ -13,6 +13,8 @@ public struct TableRowItem: Equatable { public let selectedImage: Image? /// Desekected image in edting mode. public let deSelectedImage: Image? + /// Read-only property for all cells in this row. If a cell's `isReadonly` of this row is set, then that value is used. `nil` means it is `false`. + public var isReadonly: Bool? /// Public initializer for TableRowItem /// - Parameters: @@ -21,12 +23,14 @@ public struct TableRowItem: Equatable { /// - data: Row data. /// - selectedImage: Selected image in editing mode. /// - deSelectedImage: Desekected image in edting mode. - public init(leadingAccessories: [AccessoryItem], trailingAccessory: AccessoryItem?, data: [DataItem], selectedImage: Image? = nil, deSelectedImage: Image? = nil) { + /// - isReadonly: Is the row read-only or not for the inline editing mode. + public init(leadingAccessories: [AccessoryItem], trailingAccessory: AccessoryItem?, data: [DataItem], selectedImage: Image? = nil, deSelectedImage: Image? = nil, isReadonly: Bool? = nil) { self.leadingAccessories = leadingAccessories self.trailingAccessory = trailingAccessory self.data = data self.selectedImage = selectedImage self.deSelectedImage = deSelectedImage + self.isReadonly = isReadonly } /// Public initializer for TableRowItem @@ -34,12 +38,14 @@ public struct TableRowItem: Equatable { /// - data: Row data. /// - selectedImage: Selected image in editing mode. /// - deSelectedImage: Desekected image in edting mode. - public init(data: [DataItem], selectedImage: Image? = nil, deSelectedImage: Image? = nil) { + /// - isReadonly: Is the row read-only or not for the inline editing mode. + public init(data: [DataItem], selectedImage: Image? = nil, deSelectedImage: Image? = nil, isReadonly: Bool? = nil) { self.leadingAccessories = [] self.trailingAccessory = nil self.data = data self.selectedImage = selectedImage self.deSelectedImage = deSelectedImage + self.isReadonly = isReadonly } /// check equality @@ -59,6 +65,11 @@ public struct TableRowItem: Equatable { return false } + // check isReadonly + if lhs.isReadonly != rhs.isReadonly { + return false + } + // check data if lhs.data.count != rhs.data.count { return false diff --git a/Sources/FioriSwiftUICore/Toast/ToastView.swift b/Sources/FioriSwiftUICore/Toast/ToastView.swift new file mode 100644 index 000000000..be7f21a08 --- /dev/null +++ b/Sources/FioriSwiftUICore/Toast/ToastView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct Toast: Equatable { + /// The toast message + var message: String + + /// Icon image in front of the text. The default is a checkmark icon. + var image: Image? + + /// The duration in seconds for which the toast message is shown. The default is `1`. + var duration: Double = 1 +} + +/// `ToastView` shows an overlay toast message centered within current view. +struct ToastView: View { + /// The toast message + let message: String + + /// Icon image in front of the text. The default is a checkmark icon. + var image: Image? = nil + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + GeometryReader { reader in + VStack { + Spacer() + HStack { + Spacer() + makeBody(reader.size) + Spacer() + } + Spacer() + } + } + } + + func makeBody(_ size: CGSize) -> some View { + HStack(alignment: .center, spacing: 8) { + if let image = image { + image + } else { + Image(systemName: "checkmark.circle") + .foregroundColor(Color.preferredColor(.primaryLabel)) + } + + Text(message) + .font(Font.fiori(forTextStyle: .subheadline)) + .foregroundColor(Color.preferredColor(.primaryLabel)) + } + .padding(20) + .frame(width: size.width * (self.horizontalSizeClass == .compact ? 0.8 : 0.6)) + .background(Color.preferredColor(.header, interface: .elevatedConstant)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .inset(by: 0.33) + .stroke(Color.preferredColor(.separator), lineWidth: 0.33) + ) + .shadow(color: Color.preferredColor(.sectionShadow), radius: 2) + .shadow(color: Color.preferredColor(.cardShadow), radius: 16, x: 0, y: 8) + .shadow(color: Color.preferredColor(.cardShadow), radius: 32, x: 0, y: 16) + } +} + +struct ToastModifier: ViewModifier { + @Binding var toast: Toast? + @State private var workItem: DispatchWorkItem? + + func body(content: Content) -> some View { + content + .overlay( + ZStack { + if let toast = toast { + ToastView(message: toast.message, + image: toast.image) + .animation(.easeInOut, value: toast) + } + } + ) + .onChange(of: self.toast) { _ in + showToast() + } + } + + private func showToast() { + guard let toast = toast else { return } + + if toast.duration > 0 { + self.workItem?.cancel() + + let task = DispatchWorkItem { + dismissToast() + } + + if UIAccessibility.isVoiceOverRunning && !toast.message.isEmpty { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + UIAccessibility.post(notification: .announcement, argument: toast.message) + } + } + + self.workItem = task + DispatchQueue.main.asyncAfter(deadline: .now() + toast.duration, execute: task) + } + } + + private func dismissToast() { + withAnimation(.easeInOut) { + toast = nil + } + + self.workItem?.cancel() + self.workItem = nil + } +} + +extension View { + /// Shows an overlay toast message centered within current view + func toast(toast: Binding) -> some View { + self.modifier(ToastModifier(toast: toast)) + } +} + +#Preview { + ToastView(message: "Tapped cell is read-only.") +} + +#Preview { + HStack {} + .background(Color.green) + .frame(maxWidth: /*@START_MENU_TOKEN@*/ .infinity/*@END_MENU_TOKEN@*/, maxHeight: .infinity) + .toast(toast: .constant(Toast(message: "Tapped cell is read-only."))) + .previewDevice(PreviewDevice(rawValue: "iPhone 15")) +} + +#Preview { + HStack {} + .background(Color.green) + .frame(maxWidth: /*@START_MENU_TOKEN@*/ .infinity/*@END_MENU_TOKEN@*/, maxHeight: .infinity) + .toast(toast: .constant(Toast(message: "Tapped cell is read-only. Tapped cell is read-only. Tapped cell is read-only.", image: Image(systemName: "info.circle.fill")))) + .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch) (4th generation)")) +} diff --git a/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings b/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings index 558aaee13..e05d0816f 100644 --- a/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings +++ b/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings @@ -147,3 +147,6 @@ /* XBUT: slider default value label formatter for integer */ "Value: %d" = "Value: %d"; + +/* XMSG: a toast message for read-only cell in DataTable */ +"Tapped cell is read-only." = "Tapped cell is read-only."; diff --git a/Tests/FioriSwiftUITests/FioriSwiftUICore/DataTableTests.swift b/Tests/FioriSwiftUITests/FioriSwiftUICore/DataTableTests.swift new file mode 100644 index 000000000..7e5c7c271 --- /dev/null +++ b/Tests/FioriSwiftUITests/FioriSwiftUICore/DataTableTests.swift @@ -0,0 +1,95 @@ +@testable import FioriSwiftUICore +import SwiftUI +import XCTest + +final class DataTableTests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testTextItemReadonly() throws { + let textItem0 = DataTextItem("Hello", Font.body, Color.green) + let textItem1 = DataTextItem("Hello", Font.body, Color.green, isReadonly: false) + let textItem2 = DataTextItem("Hello", Font.body, Color.green, isReadonly: true) + XCTAssertEqual(textItem0.isReadonly, nil) + XCTAssertEqual(textItem1.isReadonly, false) + XCTAssertEqual(textItem2.isReadonly, true) + } + + func testRowReadonly() throws { + let textItem1 = DataTextItem("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus mattis tristique pretium.", Font.body, Color.green) + let textItem2 = DataTextItem("Hello", Font.body, Color.green, isReadonly: true) + let imageItem = DataImageItem(Image(systemName: "sun.min"), Color.gray) + let dateItem = DataDateItem(Date(timeIntervalSinceReferenceDate: TimeInterval(100)), Font.body, Color.black, isReadonly: true) + let timeItem = DataTimeItem(Date(timeIntervalSinceReferenceDate: TimeInterval(100)), Font.body, Color.black) + let durationItem = DataDurationItem(TimeInterval(6000), Font.body, Color.black, isReadonly: false) + let listItem = DataTextItem("San Jose", Font.body, Color.green, isReadonly: true) + + let row1 = TableRowItem(data: [textItem1, imageItem, dateItem, timeItem, durationItem, listItem]) + let row2 = TableRowItem(data: [textItem1, imageItem, dateItem, timeItem, durationItem, listItem], isReadonly: false) + let row3 = TableRowItem(data: [dateItem, textItem1, imageItem, timeItem, durationItem, listItem], isReadonly: true) + let row4 = TableRowItem(data: [textItem2, dateItem, dateItem, imageItem, timeItem, durationItem, listItem]) + + XCTAssertTrue(row1.isReadonly == nil) + XCTAssertTrue(row2.isReadonly == false) + XCTAssertTrue(row3.isReadonly == true) + XCTAssertTrue(textItem1.isReadonly == nil) + XCTAssertTrue(textItem2.isReadonly == true) + XCTAssertTrue(dateItem.isReadonly == true) + XCTAssertTrue(timeItem.isReadonly == nil) + XCTAssertTrue(durationItem.isReadonly == false) + XCTAssertTrue(listItem.isReadonly == true) + } + + func testColumnReadonly() throws { + let column1Attr = ColumnAttribute(textAlignment: .leading) + let column2Attr = ColumnAttribute(textAlignment: .leading, isReadonly: false) + let column3Attr = ColumnAttribute(textAlignment: .leading, isReadonly: true) + + XCTAssertTrue(column1Attr.isReadonly == nil) + XCTAssertTrue(column2Attr.isReadonly == false) + XCTAssertTrue(column3Attr.isReadonly == true) + } + + func testMixedReadonly() throws { + let ld = LayoutData() + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: nil, columnIsReadonly: nil, cellIsReadonly: nil) == false) + + /// Set the cell only + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: nil, columnIsReadonly: nil, cellIsReadonly: false) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: nil, columnIsReadonly: nil, cellIsReadonly: true) == true) + + /// Set the row only + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: false, columnIsReadonly: nil, cellIsReadonly: nil) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: true, columnIsReadonly: nil, cellIsReadonly: nil) == true) + + /// Set the column only + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: nil, columnIsReadonly: false, cellIsReadonly: nil) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: nil, columnIsReadonly: true, cellIsReadonly: nil) == true) + + /// Set the row and column + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: false, columnIsReadonly: false, cellIsReadonly: nil) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: false, columnIsReadonly: true, cellIsReadonly: nil) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: true, columnIsReadonly: false, cellIsReadonly: nil) == true) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: true, columnIsReadonly: true, cellIsReadonly: nil) == true) + + /// Set differenct values for the row, column, and cell + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: false, columnIsReadonly: true, cellIsReadonly: false) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: true, columnIsReadonly: false, cellIsReadonly: false) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: true, columnIsReadonly: true, cellIsReadonly: false) == false) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: false, columnIsReadonly: true, cellIsReadonly: true) == true) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: true, columnIsReadonly: false, cellIsReadonly: true) == true) + XCTAssertTrue(ld.isReadonlyForCell(rowIsReadonly: true, columnIsReadonly: true, cellIsReadonly: true) == true) + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } +}