Visualize Table in SwiftUI - swiftui

How can I visualize this data as table in SwiftUI:
I have an invoice object with pages and tables and cells.
The cells do have information about rowIndex and columnIndex:
struct Cell: Codable, Hashable, Identifiable {
let id: UUID = UUID()
let rowIndex, columnIndex: Int
let text: String
let boundingBox: [Double]
let isHeader: Bool?
}
Currently I have no better idea then this do get a "new" row:
if !invoice.invoice.isEmpty {
ScrollView{
HStack {
ForEach (invoice.invoice) {invoice in
ForEach (invoice.analyzeResult.pageResults) {pageresult in
ForEach (pageresult.tables) {table in
ForEach (table.cells) {cell in
if (cell.rowIndex == 0) {
Text(cell.text)
}
}
}
}
}
}
HStack {
ForEach (invoice.invoice) {invoice in
ForEach (invoice.analyzeResult.pageResults) {pageresult in
ForEach (pageresult.tables) {table in
ForEach (table.cells) {cell in
if (cell.rowIndex == 1) {
Text(cell.text)
}
}
}
}
}
}
}
}
Any idea how to solve this?
Thanks a lot

You can use either a combination of VStack and HStack or a LazyVGrid, the latter seems more appropriate. In both cases you have to find the nr of columns (and rows for 1. case).
Here is an example with both approaches:
// create dummy data
let tableData: [Cell] = {
var result = [Cell]()
for row in 0..<10 {
for col in 0..<5 {
result.append(Cell(rowIndex: row, columnIndex: col, text: "cell \(row), \(col)", boundingBox: [], isHeader: nil))
}
}
return result
}()
struct ContentView: View {
// find row & col nrs
let rowNr = tableData.max(by: {$0.rowIndex < $1.rowIndex})?.rowIndex ?? 0
let colNr = tableData.max(by: {$0.columnIndex < $1.columnIndex})?.columnIndex ?? 0
var body: some View {
VStack {
ScrollView {
// Option 1: VStack and HStack
VStack {
ForEach(0..<rowNr+1) { row in
HStack {
ForEach(0..<colNr+1) { col in
// find cell with row + col numbers
if let cell = tableData.first(where: { $0.rowIndex == row && $0.columnIndex == col }) {
Text(cell.text)
}
}
}
}
}
Divider()
// Option 2: LazyVGrid View
let columns = [GridItem].init(repeating: GridItem(.flexible(minimum: 10)), count: colNr+1)
LazyVGrid(columns: columns) {
ForEach(tableData
.sorted(by: {$0.columnIndex < $1.columnIndex})
.sorted(by: {$0.rowIndex < $1.rowIndex}))
{ cell in
Text(cell.text)
}
}
}
}
}
}
EDIT:
As #loremipsum pointed out there might be the case, that there is no value for every combination of row/columns. In this case you could map the data to an array first, filling the empty data points:
func dataMappedToArray() -> [Cell] {
let rowNr = tableData.reduce(0, { max($0, $1.rowIndex) })
let colNr = tableData.reduce(0, { max($0, $1.columnIndex) })
var result = [Cell]()
for row in 0...rowNr {
for col in 0...colNr {
if let cell = tableData.first(where: { $0.rowIndex == row && $0.columnIndex == col }) {
result.append(cell)
} else {
// "empty" cell
result.append(Cell(rowIndex: row, columnIndex: col, text: "–", boundingBox: [], isHeader: nil) )
}
}
}
return result
}

Related

SwiftUI move List items between list Sections

In the code bellow I am trying to achieve the moving (dragging) from a list sections to another list section.
I have an enum which contains 5 elements and a state var (activeElements) that contains an array of those enum.
In a view there is a list with 2 sections:
first section contains elements that are selected from the section 2 (let's call them active elements)
the second section contains elements from the enum which are not contained in selected array
In section 1 I should be able to move elements around and also to drag an element to section2 at a desired position.
In section 2 I should be able to drag elements to section 1 and I don't care about the moving elements inside this section.
The section1 works perfect. My problem is that I cannot drag from section2 to section1. I can only drag from section 2 to section 2 (the plus green dot appears only there).
If I add onMove to the section 2 I lose the dragging from section 1 to section 2. The sections will move elements only inside them.
Do you have any suggestions on how to achieve moving from a section to another? Or maybe how I can move elements between 2 foreach in the same section.
Here is the code:
enum RandomElements: Int, CaseIterable {
case element1 = 1
case element2 = 2
case element3 = 3
case element4 = 4
case element5 = 5
}
extension RandomElements {
var string: String {
switch self {
case .element1:
return "element1"
case .element2:
return "element2"
case .element3:
return "element3"
case .element4:
return "element4"
case .element5:
return "element5"
}
}
}
struct TwoSections: View {
#State var activeElements: [RandomElements] = []
#State var listMode: EditMode = .inactive
var body: some View {
List {
Section(header: Text("Active elements")) {
ForEach(activeElements, id: \.self) { elem in
HStack {
Text(elem.string)
Spacer()
Image(systemName: "minus")
.onTapGesture { activeElements.remove(at: activeElements.firstIndex(of: elem)!) }
}
.onDrag { NSItemProvider(object: String(elem.rawValue) as NSString ) }
}
.onInsert(of: [.plainText], perform: dropToSection1)
.onMove { (indexSet, index) in
activeElements.move(fromOffsets: indexSet, toOffset: index)
}
}
Section(header: Text("Available elements")) {
ForEach(RandomElements.allCases, id: \.self) { elem in
if !activeElements.contains(elem) {
HStack {
Text(elem.string)
Spacer()
Image(systemName: "plus")
.onTapGesture { activeElements.append(elem) }
}
.onDrag { NSItemProvider(object: String(elem.rawValue) as NSString ) }
}
}
.onInsert(of: [.plainText], perform: dropToSection2)
// .onMove { (indexSet, index) in
// }
}
}
.toolbar {
EditButton()
}
.environment(\.editMode, .constant(.active))
}
private func dropToSection1(at index: Int, _ items: [NSItemProvider]) {
for item in items {
_ = item.loadObject(ofClass: String.self) { droppedString, _ in
if let statusType = Int(droppedString ?? "") {
activeElements.remove(at: activeElements.firstIndex(of: RandomElements(rawValue: statusType)!)!)
}
}
}
}
private func dropToSection2(at index: Int, _ items: [NSItemProvider]) {
for item in items {
_ = item.loadObject(ofClass: String.self) { droppedString, _ in
if let statusType = Int(droppedString ?? "") {
print("append \(RandomElements(rawValue: statusType)!)")
activeElements.insert(RandomElements(rawValue: statusType)!, at: index)
}
}
}
}
}

List unexpected behavior - How to redraw//recreate each RowView?

I have realized that some caching mechanism is present if the List's row is a View.
Let's start with a simple list:
struct ContentView: View {
#State var model = [String]()
var body: some View {
VStack {
Button("Add") {
model.append(UUID().uuidString)
}
List {
ForEach(0 ..< model.count, id:\.self) { idx in
let item = model[idx]
Print("\(idx)")
makeRow(item: item)
}
}
}
}
func makeRow(item: String) -> some View {
HStack {
Print("Row \(item)")
Text("\(item)")
}
}
}
extension View {
func Print(_ vars: Any...) -> some View {
for v in vars { print(v) }
return EmptyView()
}
}
Adding a new single row, this appears on the console:
4
Row 44899379-B7FA-4667-ADB9-86A59EA69EF0
0
Row A219638D-9C6E-42C8-9055-C5569B20D9B8
1
Row B6187186-408F-46B2-A121-840C399CB12F
2
Row 856F873F-8639-4CA4-A832-19EF92BFA7A4
3
Row 0C080275-12D9-451C-8C7A-C8B97C4DE658
Then each row is recreated.
Let's replace each row with a View:
func makeRow(item: String) -> some View {
RowView(item: item)
}
struct RowView: View {
var item: String
var body: some View {
HStack {
Print("Row \(item)")
Text("\(item)")
}
}
}
Now the console shows:
4
Row E55A0F48-5848-4CFE-8612-AB8CC799F532
0
1
2
3
Only the RowView for the just added row is created.
Apparently all the other RowViews all cached. I think this is good in most cases.
I'm working on a complex application and, in some use cases, I need to redraw all the rows.
How can I force to recreate each RowView?

How to detect that LazyVGrid's items get re-built?

In my app, LazyVGrid re-builds its contents multiple times. The number of items in the grid may vary or remain the same. Each time a particular item must be scrolled into view programmatically.
When the LazyVGrid first appears, an item can be scrolled into view using the onAppear() modifier.
Is there any way of detecting the moment when the LazyVGrid finishes re-building its items next time so that the grid can be safely scrolled?
Here is my code:
Grid
struct Grid: View {
#ObservedObject var viewModel: ViewModel
var columns: [GridItem] {
Array(repeating: .init(.flexible(), alignment: .topLeading), count: viewModel.data.count / viewModel.rows)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { scrollViewProxy in
LazyVGrid(columns: columns) {
let rowsCount = viewModel.rows
let columsCount = columns.count
ForEach((0..<rowsCount*columsCount), id: \.self) { index in
let data = viewModel.getData(for: index)
Text(data)
.id(index)
}
}
.onAppear {
// Scroll a particular item into view
let targetIndex = 32 // an arbitrary number for simplicity sake
scrollViewProxy.scrollTo(targetIndex, anchor: .top)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed, for example on device rotation
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data
// Problem: how to detect the moment when the LazyVGrid
// finishes re-building its items
// so that the grid can be safely scrolled?
let availableWidth = geometry.size.width
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}
}
}
}
}
Helper enum to determine the number of columns to show in the grid
enum ScreenWidth: Int, CaseIterable {
case extraSmall = 320
case small = 428
case middle = 568
case large = 667
case extraLarge = 1080
static func getNumberOfColumns(width: Int) -> Int {
var screenWidth: ScreenWidth = .extraSmall
for w in ScreenWidth.allCases {
if width >= w.rawValue {
screenWidth = w
}
}
var numberOfColums: Int
switch screenWidth {
case .extraSmall:
numberOfColums = 2
case .small:
numberOfColums = 3
case .middle:
numberOfColums = 4
case .large:
numberOfColums = 5
case .extraLarge:
numberOfColums = 8
}
return numberOfColums
}
}
Simplified view model
final class ViewModel: ObservableObject {
#Published private(set) var data: [String] = []
var rows: Int = 26
init() {
data = loadDataHelper(3)
}
func loadData(_ cols: Int) async {
// emulating data loading latency
await Task.sleep(UInt64(1 * Double(NSEC_PER_SEC)))
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = _self.loadDataHelper(cols)
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet")
}
return dataGrid
}
func getData(for index: Int) -> String {
if (index > data.count-1){
return "No data"
}
return data[index]
}
}
I found two solutions.
The first one is to put LazyVGrid inside ForEach with its range’s upper bound equal to an Int published variable incremented each time data is updated. In this way a new instance of LazyVGrid is created on each update so we can make use of LazyVGrid’s onAppear method to do some initialization work, in this case scroll a particular item into view.
Here is how it can be implemented:
struct Grid: View {
#ObservedObject var viewModel: ViewModel
var columns: [GridItem] {
Array(repeating: .init(.flexible(), alignment: .topLeading), count: viewModel.data.count / viewModel.rows)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { scrollViewProxy in
ForEach((viewModel.dataIndex-1..<viewModel.dataIndex), id: \.self) { dataIndex in
LazyVGrid(columns: columns) {
let rowsCount = viewModel.rows
let columsCount = columns.count
ForEach((0..<rowsCount*columsCount), id: \.self) { index in
let data = viewModel.getData(for: index)
Text(data)
.id(index)
}
}
.id(1000 + dataIndex)
.onAppear {
print("LazyVGrid, onAppear, #\(dataIndex)")
let targetItem = 32 // arbitrary number
withAnimation(.linear(duration: 0.3)) {
scrollViewProxy.scrollTo(targetItem, anchor: .top)
}
}
}
}
}
.padding(EdgeInsets(top: 20, leading: 0, bottom: 0, trailing: 0))
.onAppear {
load(availableWidth: geometry.size.width)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed.
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data.
load(availableWidth: geometry.size.width)
}
}
}
private func load(availableWidth: CGFloat){
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}
ViewModel
final class ViewModel: ObservableObject {
/*#Published*/ private(set) var data: [String] = []
#Published private(set) var dataIndex = 0
var rows: Int = 46 // arbitrary number
func loadData(_ cols: Int) async {
let newData = loadDataHelper(cols)
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = newData
_self.dataIndex += 1
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
}
return dataGrid
}
}
--------------------------------------------------------------
The second approach is based on the solution proposed by #NewDev.
The idea is to track grid items' "rendered" status and fire a callback once they have appeared after the grid re-built its contents in response to viewmodel's data change.
RenderModifier keeps track of grid item's "rendered" status using PreferenceKey to collect data.
The .onAppear() modifier is used to set "rendered" status while the .onDisappear() modifier is used to reset the status.
struct RenderedPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
value = value + nextValue() // sum all those that remain to-be-rendered
}
}
struct RenderModifier: ViewModifier {
#State private var toBeRendered = 1
func body(content: Content) -> some View {
content
.preference(key: RenderedPreferenceKey.self, value: toBeRendered)
.onAppear { toBeRendered = 0 }
.onDisappear { /*reset*/ toBeRendered = 1 }
}
}
Convenience methods on View:
extension View {
func trackRendering() -> some View {
self.modifier(RenderModifier())
}
func onRendered(_ perform: #escaping () -> Void) -> some View {
self.onPreferenceChange(RenderedPreferenceKey.self) { toBeRendered in
// Invoke the callback only when all tracked statuses have been set to 0,
// which happens when all of their .onAppear() modifiers are called
if toBeRendered == 0 { perform() }
}
}
}
Before loading new data the view model clears its current data to make the grid remove its contents. This is necessary for the .onDisappear() modifiers to get called on grid items.
final class ViewModel: ObservableObject {
#Published private(set) var data: [String] = []
var dataLoadedFlag: Bool = false
var rows: Int = 46 // arbitrary number
func loadData(_ cols: Int) async {
// Clear data to make the grid remove its items.
// This is necessary for the .onDisappear() modifier to get called on grid items.
if !data.isEmpty {
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = []
}
}
// A short pause is necessary for a grid to have time to remove its items.
// This is crucial for scrolling grid for a specific item.
await Task.sleep(UInt64(0.1 * Double(NSEC_PER_SEC)))
}
let newData = loadDataHelper(cols)
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.dataLoadedFlag = true
_self.data = newData
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet")
}
return dataGrid
}
func getData(for index: Int) -> String {
if (index > data.count-1){
return "No data"
}
return data[index]
}
}
An example of usage of the trackRendering() and onRendered() functions:
struct Grid: View {
#ObservedObject var viewModel: ViewModel
var columns: [GridItem] {
Array(repeating: .init(.flexible(), alignment: .topLeading), count: viewModel.data.count / viewModel.rows)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { scrollViewProxy in
LazyVGrid(columns: columns) {
let rowsCount = viewModel.rows
let columsCount = columns.count
ForEach((0..<rowsCount*columsCount), id: \.self) { index in
let data = viewModel.getData(for: index)
Text(data)
.id(index)
// set RenderModifier
.trackRendering()
}
}
.onAppear {
load(availableWidth: geometry.size.width)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed.
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data.
load(availableWidth: geometry.size.width)
}
.onRendered {
// do scrolling only if data was loaded,
// that is the grid was re-built
if viewModel.dataLoadedFlag {
/*reset*/ viewModel.dataLoadedFlag = false
let targetItem = 32 // arbitrary number
scrollViewProxy.scrollTo(targetItem, anchor: .top)
}
}
}
}
}
}
private func load(availableWidth: CGFloat){
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}

SwiftUI highlight word in search result

I have a search view, where user can search by a word or phrase and filter the result.
List {
ForEach(textsData) {text in
if text.sometext.localizedCaseInsensitiveContains(self.searchText) || self.searchText == "" {
NavigationLink(destination: TextView(text: text)) {
Text(text.sometext)
}
}
}
}
I would like to highlight with red color the searched text. Is there a way I can do it?
UPD:
Let's assume the code is as following:
struct ContentView: View {
var texts = ["This is an example of large text block", "This block is rather small"]
var textsSearch = "large"
var body: some View {
List {
ForEach(self.texts, id: \.self) {text in
Text(text).padding().background(self.textsSearch == text ? Color.red : .clear)
}
}
}
}
And I would like to highlight only the word "large" in output:
This is an example of large text block
This block is rather small
UPD2: This answer worked good for me: SwiftUI: is there exist modifier to highlight substring of Text() view?
This should work. You can often add conditions directly into modifiers:
struct ContentView: View {
var texts = ["a", "b"]
var textsSearch = "a"
var body: some View {
List {
ForEach(self.texts, id: \.self) {text in
Text(text).padding().background(self.textsSearch == text ? Color.red : .clear)
}
}
}
}
I had a similar issue, the text.foregroundColor it wasn't enough for me. I need to change the background color of the view. There isn't a easy way to accomplish this in SwiftUI so I created my own View that adds this capability:
import SwiftUI
struct HighlightedText: View {
/// The text rendered by current View
let text: String
/// The textPart to "highlight" if [text] contains it
var textPart: String?
/// The <Text> views created inside the current view inherits Text params defined for self (HighlightedText) like font, underline, etc
/// Color used for view background when text value contans textPart value
var textPartBgColor: Color = Color.blue
/// Font size used to determine if the current text needs more than one line for render
var fontSize: CGFloat = 18
/// Max characters length allowed for one line, if exceeds a new line will be added
var maxLineLength = 25
/// False to disable multiline drawing
var multilineEnabled = true
/// True when the current [text] needs more than one line to render
#State private var isTruncated: Bool = false
public var body: some View {
guard let textP = textPart, !textP.isEmpty else {
// 1. Default case, [textPart] is null or empty
return AnyView(Text(text))
}
let matches = collectRegexMatches(textP)
if matches.isEmpty {
// 2. [textPart] has a value but is not found in the [text] value
return AnyView(Text(text))
}
// 3. There is at least one match for [textPart] in [text]
let textParts = collectTextParts(matches, textP)
if multilineEnabled && isTruncated {
// 4. The current [text] needs more than one line to render
return AnyView(renderTruncatedContent(collectLineTextParts(textParts)))
}
// 5. The current [text] can be rendered in one line
return AnyView(renderOneLineContent(textParts))
}
#ViewBuilder
private func renderOneLineContent(_ textParts: [TextPartOption]) -> some View {
HStack(alignment: .top, spacing: 0) {
ForEach(textParts) { item in
if item.highlighted {
Text(item.textPart)
.frame(height: 30, alignment: .leading)
.background(textPartBgColor)
} else {
Text(item.textPart)
.frame(height: 30, alignment: .leading)
}
}
}.background(GeometryReader { geometry in
if multilineEnabled {
Color.clear.onAppear {
self.determineTruncation(geometry)
}
}
})
}
#ViewBuilder
private func renderTruncatedContent(_ lineTextParts: [TextPartsLine]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(lineTextParts)) { lineTextPartsItem in
HStack(alignment: .top, spacing: 0) {
ForEach(lineTextPartsItem.textParts) { textPartItem in
if textPartItem.highlighted {
Text(textPartItem.textPart)
.frame(height: 25, alignment: .leading)
.background(textPartBgColor)
} else {
Text(textPartItem.textPart)
.frame(height: 25, alignment: .leading)
}
}
}
}
}
}
private func charCount(_ textParts: [TextPartOption]) -> Int {
return textParts.reduce(0) { partialResult, textPart in
partialResult + textPart.textPart.count
}
}
private func collectLineTextParts(_ currTextParts: [TextPartOption]) -> [TextPartsLine] {
var textParts = currTextParts
var lineTextParts: [TextPartsLine] = []
var currTextParts: [TextPartOption] = []
while textParts.isNotEmpty {
let currItem = textParts.removeFirst()
let extraChars = charCount(currTextParts) + (currItem.textPart.count - 1) - maxLineLength
if extraChars > 0 && (currItem.textPart.count - 1) - extraChars > 0 {
let endIndex = currItem.textPart.index(currItem.textPart.startIndex, offsetBy: (currItem.textPart.count - 1) - extraChars)
currTextParts.append(
TextPartOption(
index: currTextParts.count,
textPart: String(currItem.textPart[currItem.textPart.startIndex..<endIndex]),
highlighted: currItem.highlighted
)
)
lineTextParts.append(TextPartsLine(textParts: currTextParts))
currTextParts = []
currTextParts.append(
TextPartOption(
index: currTextParts.count,
textPart: String(currItem.textPart[endIndex..<currItem.textPart.index(endIndex, offsetBy: extraChars)]),
highlighted: currItem.highlighted
)
)
} else {
currTextParts.append(currItem.copy(index: currTextParts.count))
}
}
if currTextParts.isNotEmpty {
lineTextParts.append(TextPartsLine(textParts: currTextParts))
}
return lineTextParts
}
private func collectTextParts(_ matches: [NSTextCheckingResult], _ textPart: String) -> [TextPartOption] {
var textParts: [TextPartOption] = []
// 1. Adding start non-highlighted text if exists
if let firstMatch = matches.first, firstMatch.range.location > 0 {
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[text.startIndex..<text.index(text.startIndex, offsetBy: firstMatch.range.location)]),
highlighted: false
)
)
}
// 2. Adding highlighted text matches and non-highlighted texts in-between
var lastMatchEndIndex: String.Index?
for (index, match) in matches.enumerated() {
let startIndex = text.index(text.startIndex, offsetBy: match.range.location)
if (match.range.location + textPart.count) > text.count {
lastMatchEndIndex = text.endIndex
} else {
lastMatchEndIndex = text.index(startIndex, offsetBy: textPart.count)
}
// Adding highlighted string
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[startIndex..<lastMatchEndIndex!]),
highlighted: true
)
)
if (matches.count > index + 1 ) && (matches[index + 1].range.location != (match.range.location + textPart.count)) {
// There is a non-highlighted string between highlighted strings
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[lastMatchEndIndex!..<text.index(text.startIndex, offsetBy: matches[index + 1].range.location)]),
highlighted: false
)
)
}
}
// 3. Adding end non-highlighted text if exists
if let lastMatch = matches.last, lastMatch.range.location < text.count {
textParts.append(
TextPartOption(
index: textParts.count,
textPart: String(text[lastMatchEndIndex!..<text.endIndex]),
highlighted: false
)
)
}
return textParts
}
private func collectRegexMatches(_ match: String) -> [NSTextCheckingResult] {
let pattern = NSRegularExpression.escapedPattern(for: match)
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: .regularExpression, locale: .current)
// swiftlint:disable:next force_try
return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive).matches(
in: text, options: .withTransparentBounds,
range: NSRange(location: 0, length: text.count)
)
}
private func determineTruncation(_ geometry: GeometryProxy) {
// Calculate the bounding box we'd need to render the
// text given the width from the GeometryReader.
let total = self.text.boundingRect(
with: CGSize(
width: geometry.size.width,
height: .greatestFiniteMagnitude
),
options: .usesLineFragmentOrigin,
attributes: [.font: UIFont.systemFont(ofSize: fontSize)],
context: nil
)
if total.size.height > geometry.size.height {
isTruncated = true
} else {
isTruncated = false
}
}
private struct TextPartOption: Identifiable {
let index: Int
let textPart: String
let highlighted: Bool
var id: String { "\(index)_\(textPart)" }
func copy(index: Int? = nil, textPart: String? = nil, highlighted: Bool? = nil) -> TextPartOption {
return TextPartOption(
index: index ?? self.index,
textPart: textPart ?? self.textPart,
highlighted: highlighted ?? self.highlighted
)
}
}
private struct TextPartsLine: Identifiable {
let textParts: [TextPartOption]
var id: String { textParts.reduce("") { partialResult, textPartOption in
"\(partialResult)_\(textPartOption.id)"
} }
}
}
Usage:
HighlightedText(
text: item.title,
textPart: searchQuery
)
.padding(.bottom, 2)
.foregroundColor(Color.secondaryDark)
.font(myFont)
Examples of the result:
- More than one list item result example
- One Item result example

SwiftUI how to Reorder in Landscape and Portrait

we use a custom gridView to display all Products in a grid.
the problem is... we need to reorder if the Device is in Landscape or Portait.
How can we do that?
ModularGridStyle(columns: 2, rows: 3)
columns and rows needs to be different if device is in landscape.
here is the View
var body: some View {
Grid(self.products) { product in
Text("\(product.name)")
}
.gridStyle(
ModularGridStyle(columns: 2, rows: 3)
)
}
I would use verticalSizeClass.
#Environment(\.verticalSizeClass) private var verticalSizeClass: UserInterfaceSizeClass?
var body: some View {
Grid(self.products) { Text(verbatim: "\($0.name)") }
.gridStyle(
ModularGridStyle(
columns: self.verticalSizeClass == .compact ? 3 : 2,
rows: self.verticalSizeClass == .compact ? 2 : 3
)
)
}
}
I personally prefer using a GeometryReader to setup different views for portrait/landscape. Of course, it's a bit redundant, but usually you have other properties that change as well:
var body: some View {
GeometryReader() { g in
if g.size.width < g.size.height {
// view in portrait mode
Grid(self.products) { product in
Text("\(product.name)")
}
.gridStyle(
ModularGridStyle(columns: 2, rows: 3)
)
} else {
// view in landscape mode
Grid(self.products) { product in
Text("\(product.name)")
}
.gridStyle(
ModularGridStyle(columns: 3, rows: 2)
)
}
}
}
Here is possible approach:
private var deviceOrientationPublisher = NotificationCenter.default.publisher(for:
UIDevice.orientationDidChangeNotification)
#State var dimention: (Int, Int) = (2, 3)
var body: some View {
Grid(self.products) { product in
Text("\(product.name)")
}
.gridStyle(
ModularGridStyle(columns: dimention.0, rows: dimention.1)
)
.onReceive(deviceOrientationPublisher) { _ in
self.dimention = UIDevice.current.orientation.isLandscape ? (3, 2) : (2, 3)
}
}
I looked for a more simple & elegant solution -
and found this code in - hackingwithswift
// Our custom view modifier to track rotation and
// call our action
struct DeviceRotationViewModifier: ViewModifier {
let action: (UIDeviceOrientation) -> Void
func body(content: Content) -> some View {
content
.onAppear()
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
action(UIDevice.current.orientation)
}
}
}
// A View wrapper to make the modifier easier to use
extension View {
func onRotate(perform action: #escaping (UIDeviceOrientation) -> Void) -> some View {
self.modifier(DeviceRotationViewModifier(action: action))
}
}
// An example view to demonstrate the solution
struct ContentView: View {
#State private var orientation = UIDeviceOrientation.unknown
var body: some View {
Group {
if orientation.isPortrait {
Text("Portrait")
} else if orientation.isLandscape {
Text("Landscape")
} else if orientation.isFlat {
Text("Flat")
} else {
Text("Unknown")
}
}
.onRotate { newOrientation in
orientation = newOrientation
}
}
}