SwiftUI highlight word in search result - swiftui

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

Related

SwiftUI Preference Key to make uniformly sized boxes

UPDATE: I've made this minimally reproducible.
I wish to make a grid for the alphabet, with each box the same size, looking like this:
I have a PreferenceKey, and a View extension, like this:
struct WidthPreference: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
extension View {
func sizePreference(letterIdx: Int) -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
My primary view is an HStack nestled in a VStack, with each letter as a separate view. Here is the ContentView, and its Alphabet Grid:
struct ContentView: View {
#StateObject var theModel = MyModel()
var body: some View {
AlphabetGrid()
.textCase(.uppercase)
.font(.body)
.onAppear() {
theModel.initializeLetters()
}
.environmentObject(theModel)
}
}
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue)
}
}
}
}
}
}
}
And then the Letter view, for each letter:
struct LetterView: View {
#EnvironmentObject var theModel: MyModel
var theIdx: Int
var borderColour: Color
var spacing: CGFloat = 8
#State private var cellWidth: CGFloat? = nil
func letterFor(letterIdx: Int) -> some View {
Text(String(theModel.allLetters?[letterIdx].letterStoreChar ?? "*"))
.sizePreference(letterIdx: letterIdx)
.frame(width: cellWidth, height: cellWidth, alignment: .center)
.padding(spacing)
}
var body: some View {
self.letterFor(letterIdx: theIdx)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(borderColour, lineWidth: 1)
)
.onPreferenceChange(WidthPreference.self) { self.cellWidth = $0 }
}
}
Finally, for completeness, the Model to store the letters:
class MyModel: ObservableObject {
#Published var allLetters: [LetterData]?
struct LetterData: Hashable {
let letterStorePosition: Int
let letterStoreChar: Character
let keyboardRow: Int
let keyboardCol: Int
}
let keyboardWide = 9 // characters per row
// put all the alphabet characters into an array of LetterData elements
func initializeLetters() {
var tempLetters: [LetterData] = []
let allChars = Array("abcdefghijklmnopqrstuvwxyz")
for (index, element) in allChars.enumerated() {
let row = index / keyboardWide
let col = index % keyboardWide
tempLetters.append(LetterData(letterStorePosition: index, letterStoreChar: element,
keyboardRow: row, keyboardCol: col))
}
allLetters = tempLetters
}
}
Unfortunately, this makes a pretty, yet incorrect grid like this:
Any ideas on where I'm going wrong?
I did some digging, your PreferenceKey is being set with .background which just takes the size of the current View and you are using that value to turn into a square.
There is no match for the average just taking the current width and using it for the height.
extension View {
func sizePreference(letterIdx: Int) -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
.frame(width: cellWidth, height: cellWidth, alignment: .center)
The width is based on the letter I being the most narrow and W being the widest.
Now, how to "fix" your code. You can move the onPreferenceChange up one View and use the min between the current cellWidth and the $0 instead of just replacing.
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
#State private var cellWidth: CGFloat = .infinity
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue, cellWidth: $cellWidth)
}
}
}
}
} .onPreferenceChange(WidthPreference.self) { self.cellWidth = min(cellWidth, $0 ?? .infinity) }
}
}
Now with that fix you get a better looking keyboard but the M and W are cut off, to use the max you need a little more tweaking, ou can look at the code below.
import SwiftUI
class MyModel: ObservableObject {
#Published var allLetters: [LetterData]?
struct LetterData: Hashable {
let letterStorePosition: Int
let letterStoreChar: Character
let keyboardRow: Int
let keyboardCol: Int
}
let keyboardWide = 9 // characters per row
// put all the alphabet characters into an array of LetterData elements
func initializeLetters() {
var tempLetters: [LetterData] = []
let allChars = Array("abcdefghijklmnopqrstuvwxyz")
for (index, element) in allChars.enumerated() {
let row = index / keyboardWide
let col = index % keyboardWide
tempLetters.append(LetterData(letterStorePosition: index, letterStoreChar: element,
keyboardRow: row, keyboardCol: col))
}
allLetters = tempLetters
}
}
struct AlphabetParentView: View {
#StateObject var theModel = MyModel()
var body: some View {
AlphabetGrid()
.textCase(.uppercase)
.font(.body)
.onAppear() {
theModel.initializeLetters()
}
.environmentObject(theModel)
}
}
struct LetterView: View {
#EnvironmentObject var theModel: MyModel
var theIdx: Int
var borderColour: Color
var spacing: CGFloat = 8
#Binding var cellWidth: CGFloat?
func letterFor(letterIdx: Int) -> some View {
Text(String(theModel.allLetters?[letterIdx].letterStoreChar ?? "*"))
.padding(spacing)
}
var body: some View {
RoundedRectangle(cornerRadius: 8)
.stroke(borderColour, lineWidth: 1)
.overlay {
self.letterFor(letterIdx: theIdx)
}
.frame(width: cellWidth, height: cellWidth, alignment: .center)
}
}
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
#State private var cellWidth: CGFloat? = nil
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue, cellWidth: $cellWidth)
.sizePreference()
}
}
}
}
} .onPreferenceChange(WidthPreference.self) {
if let w = cellWidth{
self.cellWidth = min(w, $0 ?? .infinity)
}else{
self.cellWidth = $0
}
}
}
}
struct AlphabetParentView_Previews: PreviewProvider {
static var previews: some View {
AlphabetParentView()
}
}
struct WidthPreference: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
extension View {
func sizePreference() -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
There are simpler way of handling this like Ashley's example or SwiftUI.Layout and layout but this should help you understand why your squares were uneven.
Here's a fairly simple implementation, using a GeometryReader to allow us to calculate the width (and therefore the height), of each letter
struct ContentView: View {
let letters = ["ABCDEFGHI","JKLMNOPQR","STUVWXYZ"]
let spacing: CGFloat = 8
var body: some View {
GeometryReader { proxy in
VStack(spacing: spacing) {
ForEach(letters, id: \.self) { row in
HStack(spacing: spacing) {
ForEach(Array(row), id: \.self) { letter in
Text(String(letter))
.frame(width: letterWidth(for: proxy.size.width), height: letterWidth(for: proxy.size.width))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.cyan, lineWidth: 1)
)
}
}
}
}
}
.padding()
}
func letterWidth(for width: CGFloat) -> CGFloat {
let count = CGFloat(letters.map(\.count).max()!)
return (width - (spacing * (count - 1))) / count
}
}

Property Wrapper doesn't affect TextField

I wrote MaxCount propertyWrapper to limit String count in TextField. However, while Text view shows trimmed String, TextField shows full String.
I can achieve expected behavior via below ViewModifier, but this doesn't seem a good practice to me, I would like to achieve that behaviour via #propertyWrapper.
TextField("Type here...", text: $text)
.onChange(of: text) { newText in
// Check if newText has more characters than maxCount, if so trim it.
guard maxCount < newText.count else { text = newText; return }
text = String(newText.prefix(maxCount))
}
MaxCount.swift
#propertyWrapper struct MaxCount<T: RangeReplaceableCollection>: DynamicProperty {
// MARK: Properties
private var count: Int = 0
#State private var value: T = .init()
var wrappedValue: T {
get { value }
nonmutating set {
value = limitValue(newValue, count: count)
}
}
var projectedValue: Binding<T> {
Binding(
get: { value },
set: { wrappedValue = $0 }
)
}
// MARK: Initilizations
init(wrappedValue: T, _ count: Int) {
self.count = count
self._value = State(wrappedValue: limitValue(wrappedValue, count: count))
}
// MARK: Functions
private func limitValue(_ value: T, count: Int) -> T {
guard value.count > count else { return value }
let lastIndex = value.index(value.startIndex, offsetBy: count - 1)
let firstIndex = value.startIndex
return T(value[firstIndex...lastIndex])
}
}
ContentView.swift
struct ContentView: View {
#MaxCount(5) private var text = "This is a test text"
var body: some View {
VStack {
Text(text)
TextField("Type here...", text: $text)
}
}
}
I ended up building a new TextField as below.
Drawback: It doesn't support initialization with formatters which exists in TextField
struct FilteredTextField<Label: View>: View {
// MARK: Properties
private let label: Label
private var bindingText: Binding<String>
private let prompt: Text?
private let filter: (String) -> Bool
#State private var stateText: String
#State private var lastValidText: String = ""
// MARK: Initializations
init(text: Binding<String>, prompt: Text? = nil, label: () -> Label, filter: ((String) -> Bool)? = nil) {
self.label = label()
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
init(_ titleKey: LocalizedStringKey, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
self.label = Text(titleKey)
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
init(_ title: String, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
self.label = Text(title)
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
// MARK: View
var body: some View {
TextField(text: $stateText, prompt: prompt, label: { label })
.onChange(of: stateText) { newValue in
guard newValue != bindingText.wrappedValue else { return }
guard filter(newValue) else { stateText = lastValidText; return }
bindingText.wrappedValue = newValue
}
.onChange(of: bindingText.wrappedValue) { newValue in
if filter(newValue) { lastValidText = newValue }
stateText = newValue
}
}
}
Usage
struct ContentView: View {
#State var test: String = ""
var body: some View {
VStack {
HStack {
Text("Default TextField")
TextField(text: $test, label: { Text("Type here...") })
}
HStack {
Text("FilteredTextField")
FilteredTextField(text: $test, label: { Text("Type here...") }) { inputString in inputString.count <= 5 }
}
}
}
}

SwiftUI - Is it possible to declare a Stack or Group as a property?

I first want to parse a large text file and collect all the necessary views before I finally display them. I have it working with an array of AnyView but it's really not nice since it erases all types.
Basically, I just want a container to collect views inside until I finally display them.
So I was wondering if I could do something like this:
class Demo {
var content = VStack()
private func mapInput() {
// ...
}
private func parse() {
for word in mappedInput { // mappedInput is the collection of tags & words that is done before
switch previous {
case "i":
content.add(Text(word).italic())
case "h":
content.add(Text(word).foregroundColor(.green))
case "img":
content.add(Image(word))
}
}
}
}
And then do something with the VStack later. But I get the following errors:
Error: Generic parameter 'Content' could not be inferred
Explicitly specify the generic arguments to fix this issue
Error: Missing argument for parameter 'content' in call
Insert ', content: <#() -> _#>'
Edit:
I have attempted to do it with the normal ViewBuilder instead. The problem here is that it's all separate Texts now that don't look like one text.
struct ViewBuilderDemo: View {
private let exampleInputString =
"""
<i>Welcome.</i><h>Resistance deepens the negative thoughts, acceptance</h><f>This will be bold</f><h>higlight reel</h><f>myappisgood</f>lets go my friend tag parsin in SwiftUI xcode 13 on Mac<img>xcode</img>Mini<f>2020</f><eh>One is beating oneself up, <img>picture</img>the other for looking opportunities. <h>One is a disempowering question, while the other empowers you.</h> Unfortunately, what often comes with the first type of questions is a defensive mindset. You start thinking of others as rivals; you have to ‘fight’ for something so they can't have it, because if one of them gets it then you automatically lose it.
"""
private var mappedInput: [String]
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(Array(zip(mappedInput.indices, mappedInput)), id: \.0) { index, word in
if index > 0 {
if !isTag(tag: word) {
let previous = mappedInput[index - 1]
switch previous {
case "i":
Text("\(word) ")
.italic()
.foregroundColor(.gray)
case "h":
Text("\(word) ")
.foregroundColor(.green)
.bold()
case "f":
Text("\(word) ")
.bold()
case "eh":
Divider()
.frame(maxWidth: 200)
.padding(.top, 24)
.padding(.bottom, 24)
case "img":
Image(word)
.resizable()
.scaledToFit()
.frame(width: UIScreen.main.bounds.width * 0.7, height: 150)
default:
Text("\(word) ")
}
}
}
}
}
.padding()
}
}
init() {
let separators = CharacterSet(charactersIn: "<>")
mappedInput = exampleInputString.components(separatedBy: separators).filter{$0 != ""}
}
private func isTag(tag currentTag: String) -> Bool {
for tag in Tags.allCases {
if tag.rawValue == currentTag {
return true
}
}
return false
}
enum Tags: String, CaseIterable {
case h = "h"
case hEnd = "/h"
case b = "f"
case bEnd = "/f"
case i = "i"
case iEnd = "/i"
case eh = "eh"
case img = "img"
case imgEnd = "/img"
}
}
So it seems that it is not possible, Group, VStack or similar are not meant to be stored like that. Instead, one has to store the content in some other way and then dynamically recreate it in a ViewBuilder (or just body).
This is how I ended up doing it:
Separate class for the parser, which uses two separate arrays. One to keep track of the content type and order (e.g. 1. text, 2. image, 3. divider etc.) and then one for the Texts only, so all the markdown formatting can be saved for later use in the ViewBuilder.
class TextParserTest {
var inputText: String
var contentStructure: [(contentType: ContentTag, content: String)] = []
var separatedTextObjects = [Text("")]
private var activeTextArrayIndex = 0
private var lastElementWasText = false
private var mappedInput: [String]
init(inputString: String) {
self.inputText = inputString
let newString = inputString.replacingOccurrences(of: "<eh>", with: "<eh>.</eh>")
let separators = CharacterSet(charactersIn: "<>")
mappedInput = newString.components(separatedBy: separators)
parse()
}
private func isTag(word: String) -> Bool {
for tag in RawTags.allCases {
if tag.rawValue == word {
return true
}
}
return false
}
private func parse() {
for (index, word) in mappedInput.enumerated() {
if index > 0 {
if !isTag(word: word) {
let tag = mappedInput[index - 1]
applyStyle(tag: tag, word: word)
}
}
else if (index == 0 && !isTag(word: word)) {
var text = separatedTextObjects[activeTextArrayIndex]
.foregroundColor(.black)
text = text + Text("\(word) ")
separatedTextObjects[activeTextArrayIndex] = text
}
}
if (lastElementWasText) {
contentStructure.append((contentType: ContentTag.text, content: "\(activeTextArrayIndex)"))
}
}
private func applyStyle(tag: String, word: String) {
var text = separatedTextObjects[activeTextArrayIndex]
.foregroundColor(.black)
switch tag {
case "i":
text = text +
Text("\(word)")
.italic()
.foregroundColor(.gray)
separatedTextObjects[activeTextArrayIndex] = text
lastElementWasText = true
case "h":
text = text +
Text("\(word)")
.foregroundColor(.accentColor)
.bold()
.kerning(2)
separatedTextObjects[activeTextArrayIndex] = text
lastElementWasText = true
case "f":
text = text +
Text("\(word)")
.bold()
separatedTextObjects[activeTextArrayIndex] = text
lastElementWasText = true
case "eh":
contentStructure.append((contentType: ContentTag.text, content: "\(activeTextArrayIndex)"))
contentStructure.append((contentType: ContentTag.divider, content: ""))
separatedTextObjects.append(Text(""))
activeTextArrayIndex += 1
lastElementWasText = false
case "img":
contentStructure.append((contentType: ContentTag.text, content: "\(activeTextArrayIndex)"))
contentStructure.append((contentType: ContentTag.image, content: "\(word)"))
separatedTextObjects.append(Text(""))
activeTextArrayIndex += 1
lastElementWasText = false
default:
text = text +
Text("\(word)")
separatedTextObjects[activeTextArrayIndex] = text
lastElementWasText = true
}
}
private enum RawTags: String, CaseIterable {
case h = "h"
case hEnd = "/h"
case b = "f"
case bEnd = "/f"
case i = "i"
case iEnd = "/i"
case eh = "eh"
case ehEnd = "/eh"
case img = "img"
case imgEnd = "/img"
}
}
enum ContentTag: String {
case text = "text"
case image = "image"
case divider = "divider"
}
And then use it with SwiftUI like this:
struct ViewBuilderDemo2: View {
private let exampleInputString =
"""
<i>Welcome.</i><h>Resistance deepens the negative thoughts, acceptance</h><f>This will be bold</f><h>higlight reel</h><f>myappisgood</f>lets go my friend tag parsin in SwiftUI xcode 13 on Mac<img>xcode</img>Mini<f>2020</f><eh>One is beating oneself up, <img>picture</img>the other for looking opportunities. <h>One is a disempowering question, while the other empowers you.</h> Unfortunately, what often comes with the first type of questions is a defensive mindset. You start thinking of others as rivals; you have to ‘fight’ for something so they can't have it, because if one of them gets it then you <eh><f>automatically</f> lose it. Kelb kelb text lorem ipsum and more.
"""
var parser: TextParserTest
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(parser.contentStructure.indices, id: \.self) { i in
let contentType = parser.contentStructure[i].contentType
let content = parser.contentStructure[i].content
switch contentType {
case ContentTag.divider:
Divider()
.frame(maxWidth: 200)
.padding(.top, 24)
.padding(.bottom, 24)
case ContentTag.image:
Image(content)
.resizable()
.scaledToFit()
.frame(width: UIScreen.main.bounds.width * 0.7, height: 150)
.padding(.top, 24)
.padding(.bottom, 24)
case ContentTag.text:
let textIndex = Int(content) ?? 0
parser.separatedTextObjects[textIndex]
}
}
}
.padding()
}
}
init() {
parser = TextParserTest(inputString: exampleInputString)
}
}

Implementing a tag list in SwiftUI

I am trying to implement a tag list in SwiftUI but I'm unsure how to get it to wrap the tags to additional lines if the list overflows horizontally. I started with a string array called tags and within SwiftUI I loop through the array and create buttons as follows:
HStack{
ForEach(tags, id: \.self){tag in
Button(action: {}) {
HStack {
Text(tag)
Image(systemName: "xmark.circle")
}
}
.padding()
.foregroundColor(.white)
.background(Color.orange)
.cornerRadius(.infinity)
.lineLimit(1)
}
}
If the tags array is small it renders as follows:
However, if the array has more values it does this:
The behavior I am looking for is for the last tag (yellow) to wrap to the second line. I realize it is in an HStack, I was hoping I could add a call to lineLimit with a value of greater than one but it doesn't seem to change the behavior. If I change the outer HStack to a VStack, it puts each Button on a separate line, so still not quite the behavior I am trying create. Any guidance would be greatly appreciated.
Federico Zanetello shared a nice solution in his blog: Flexible layouts in SwiftUI.
The solution is a custom view called FlexibleView which computes the necessary Row's and HStack's to lay down the given elements and wrap them into multiple rows if needed.
struct _FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
let availableWidth: CGFloat
let data: Data
let spacing: CGFloat
let alignment: HorizontalAlignment
let content: (Data.Element) -> Content
#State var elementsSize: [Data.Element: CGSize] = [:]
var body : some View {
VStack(alignment: alignment, spacing: spacing) {
ForEach(computeRows(), id: \.self) { rowElements in
HStack(spacing: spacing) {
ForEach(rowElements, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
}
func computeRows() -> [[Data.Element]] {
var rows: [[Data.Element]] = [[]]
var currentRow = 0
var remainingWidth = availableWidth
for element in data {
let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]
if remainingWidth - (elementSize.width + spacing) >= 0 {
rows[currentRow].append(element)
} else {
currentRow = currentRow + 1
rows.append([element])
remainingWidth = availableWidth
}
remainingWidth = remainingWidth - (elementSize.width + spacing)
}
return rows
}
}
Usage:
FlexibleView(
data: [
"Here’s", "to", "the", "crazy", "ones", "the", "misfits", "the", "rebels", "the", "troublemakers", "the", "round", "pegs", "in", "the", "square", "holes", "the", "ones", "who", "see", "things", "differently", "they’re", "not", "fond", "of", "rules"
],
spacing: 15,
alignment: .leading
) { item in
Text(verbatim: item)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
)
}
.padding(.horizontal, model.padding)
}
Full code available at https://github.com/zntfdr/FiveStarsCodeSamples.
Ok, this is my first answer on this site, so bear with me if I commit some kind of stack overflow faux pas.
I'll post my solution, which works for a model where the tags are either present in a selectedTags set or not, and all available tags are present in an allTags set. In my solution, these are set as bindings, so they can be injected from elsewhere in the app. Also, my solution has the tags ordered alphabetically because that was easiest. If you want them ordered a different way, you'll probably need to use a different model than two independent sets.
This definitely won't work for everyone's use case, but since I couldn't find my own answer for this out there, and your question was the only place I could find mentioning the idea, I decided I would try to build something that would work for me and share it with you. Hope it helps:
struct TagList: View {
#Binding var allTags: Set<String>
#Binding var selectedTags: Set<String>
private var orderedTags: [String] { allTags.sorted() }
private func rowCounts(_ geometry: GeometryProxy) -> [Int] { TagList.rowCounts(tags: orderedTags, padding: 26, parentWidth: geometry.size.width) }
private func tag(rowCounts: [Int], rowIndex: Int, itemIndex: Int) -> String {
let sumOfPreviousRows = rowCounts.enumerated().reduce(0) { total, next in
if next.offset < rowIndex {
return total + next.element
} else {
return total
}
}
let orderedTagsIndex = sumOfPreviousRows + itemIndex
guard orderedTags.count > orderedTagsIndex else { return "[Unknown]" }
return orderedTags[orderedTagsIndex]
}
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
ForEach(0 ..< self.rowCounts(geometry).count, id: \.self) { rowIndex in
HStack {
ForEach(0 ..< self.rowCounts(geometry)[rowIndex], id: \.self) { itemIndex in
TagButton(title: self.tag(rowCounts: self.rowCounts(geometry), rowIndex: rowIndex, itemIndex: itemIndex), selectedTags: self.$selectedTags)
}
Spacer()
}.padding(.vertical, 4)
}
Spacer()
}
}
}
}
struct TagList_Previews: PreviewProvider {
static var previews: some View {
TagList(allTags: .constant(["one", "two", "three"]), selectedTags: .constant(["two"]))
}
}
extension String {
func widthOfString(usingFont font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let size = self.size(withAttributes: fontAttributes)
return size.width
}
}
extension TagList {
static func rowCounts(tags: [String], padding: CGFloat, parentWidth: CGFloat) -> [Int] {
let tagWidths = tags.map{$0.widthOfString(usingFont: UIFont.preferredFont(forTextStyle: .headline))}
var currentLineTotal: CGFloat = 0
var currentRowCount: Int = 0
var result: [Int] = []
for tagWidth in tagWidths {
let effectiveWidth = tagWidth + (2 * padding)
if currentLineTotal + effectiveWidth <= parentWidth {
currentLineTotal += effectiveWidth
currentRowCount += 1
guard result.count != 0 else { result.append(1); continue }
result[result.count - 1] = currentRowCount
} else {
currentLineTotal = effectiveWidth
currentRowCount = 1
result.append(1)
}
}
return result
}
}
struct TagButton: View {
let title: String
#Binding var selectedTags: Set<String>
private let vPad: CGFloat = 13
private let hPad: CGFloat = 22
private let radius: CGFloat = 24
var body: some View {
Button(action: {
if self.selectedTags.contains(self.title) {
self.selectedTags.remove(self.title)
} else {
self.selectedTags.insert(self.title)
}
}) {
if self.selectedTags.contains(self.title) {
HStack {
Text(title)
.font(.headline)
}
.padding(.vertical, vPad)
.padding(.horizontal, hPad)
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(radius)
.overlay(
RoundedRectangle(cornerRadius: radius)
.stroke(Color(UIColor.systemBackground), lineWidth: 1)
)
} else {
HStack {
Text(title)
.font(.headline)
.fontWeight(.light)
}
.padding(.vertical, vPad)
.padding(.horizontal, hPad)
.foregroundColor(.gray)
.overlay(
RoundedRectangle(cornerRadius: radius)
.stroke(Color.gray, lineWidth: 1)
)
}
}
}
}
I found this gist which once built, looks amazing! It did exactly what I needed for making and deleting tags. Here is a sample I built for a multi platform swift app from the code.
Tagger View
struct TaggerView: View {
#State var newTag = ""
#State var tags = ["example","hello world"]
#State var showingError = false
#State var errorString = "x" // Can't start empty or view will pop as size changes
var body: some View {
VStack(alignment: .leading) {
ErrorMessage(showingError: $showingError, errorString: $errorString)
TagEntry(newTag: $newTag, tags: $tags, showingError: $showingError, errorString: $errorString)
TagList(tags: $tags)
}
.padding()
.onChange(of: showingError, perform: { value in
if value {
// Hide the error message after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
showingError = false
}
}
})
}
}
ErrorMessage View
struct ErrorMessage: View {
#Binding var showingError: Bool
#Binding var errorString: String
var body: some View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(errorString)
.foregroundColor(.secondary)
.padding(.leading, -6)
}
.font(.caption)
.opacity(showingError ? 1 : 0)
.animation(.easeIn(duration: 0.3), value: showingError)
}
}
TagEntry View
struct TagEntry: View {
#Binding var newTag: String
#Binding var tags: [String]
#Binding var showingError: Bool
#Binding var errorString: String
var body: some View {
HStack {
TextField("Add Tags", text: $newTag, onCommit: {
addTag(newTag)
})
.textFieldStyle(RoundedBorderTextFieldStyle())
Spacer()
Image(systemName: "plus.circle")
.foregroundColor(.blue)
.onTapGesture {
addTag(newTag)
}
}
.onChange(of: newTag, perform: { value in
if value.contains(",") {
// Try to add the tag if user types a comma
newTag = value.replacingOccurrences(of: ",", with: "")
addTag(newTag)
}
})
}
/// Checks if the entered text is valid as a tag. Sets the error message if it isn't
private func tagIsValid(_ tag: String) -> Bool {
// Invalid tags:
// - empty strings
// - tags already in the tag array
let lowerTag = tag.lowercased()
if lowerTag == "" {
showError(.Empty)
return false
} else if tags.contains(lowerTag) {
showError(.Duplicate)
return false
} else {
return true
}
}
/// If the tag is valid, it is added to an array, otherwise the error message is shown
private func addTag(_ tag: String) {
if tagIsValid(tag) {
tags.append(newTag.lowercased())
newTag = ""
}
}
private func showError(_ code: ErrorCode) {
errorString = code.rawValue
showingError = true
}
enum ErrorCode: String {
case Empty = "Tag can't be empty"
case Duplicate = "Tag can't be a duplicate"
}
}
TagList View
struct TagList: View {
#Binding var tags: [String]
var body: some View {
GeometryReader { geo in
generateTags(in: geo)
.padding(.top)
}
}
/// Adds a tag view for each tag in the array. Populates from left to right and then on to new rows when too wide for the screen
private func generateTags(in geo: GeometryProxy) -> some View {
var width: CGFloat = 0
var height: CGFloat = 0
return ZStack(alignment: .topLeading) {
ForEach(tags, id: \.self) { tag in
Tag(tag: tag, tags: $tags)
.alignmentGuide(.leading, computeValue: { tagSize in
if (abs(width - tagSize.width) > geo.size.width) {
width = 0
height -= tagSize.height
}
let offset = width
if tag == tags.last ?? "" {
width = 0
} else {
width -= tagSize.width
}
return offset
})
.alignmentGuide(.top, computeValue: { tagSize in
let offset = height
if tag == tags.last ?? "" {
height = 0
}
return offset
})
}
}
}
}
Tag View
struct Tag: View {
var tag: String
#Binding var tags: [String]
#State var fontSize: CGFloat = 20.0
#State var iconSize: CGFloat = 20.0
var body: some View {
HStack {
Text(tag.lowercased())
.font(.system(size: fontSize, weight: .regular, design: .rounded))
.padding(.leading, 2)
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.red, .blue, .white)
.font(.system(size: iconSize, weight: .black, design: .rounded))
.opacity(0.7)
.padding(.leading, -5)
}
.foregroundColor(.white)
.font(.caption2)
.padding(4)
.background(Color.blue.cornerRadius(5))
.padding(4)
.onTapGesture {
tags = tags.filter({ $0 != tag })
}
}
}
And finally…
Context View
import SwiftUI
struct ContentView: View {
var body: some View {
TaggerView()
}
}
I can’t take any credit for the code but let me send a huge thanks to Alex Hay for creating and posting this.
Link to the gist code on GitHub
I hope this helps someone.

Format text field to 2 decimal places without entering a decimal (SwiftUI)

In a text field, I'd like, when a user enters a number e.g. 12345, it gets formatted as 123.45. The user never needs to enter a decimal place, it just uses the 2 right most numbers as the decimal places. The field should only allow numbers too. This is for a SwiftUI project. Thanks in advance for any assistance.
Because there of a two way binding between what you enter and what is being shown in the TextField view it seems not possible to interpolate the displayed number entered. I would suggest a small hack:
create a ZStack with a TextField and a Text View superimposed.
the foreground font of the entered text in the TextField is clear or white .foregroundColor(.clear)
the keyboard is only number without decimal point: .keyboardType(.numberPad)
use .accentColor(.clear) to hide the cursor
the results are displayed in a Text View with formatting specifier: "%.2f"
It would look like
This is the code:
struct ContentView: View {
#State private var enteredNumber = ""
var enteredNumberFormatted: Double {
return (Double(enteredNumber) ?? 0) / 100
}
var body: some View {
Form {
Section {
ZStack(alignment: .leading) {
TextField("", text: $enteredNumber)
.keyboardType(.numberPad).foregroundColor(.clear)
.textFieldStyle(PlainTextFieldStyle())
.disableAutocorrection(true)
.accentColor(.clear)
Text("\(enteredNumberFormatted, specifier: "%.2f")")
}
}
}
}
}
With Swift UI the complete solution is
TextField allow numeric value only
Should accept only one comma (".")
Restrict decimal point upto x decimal place
File NumbersOnlyViewModifier
import Foundation
import SwiftUI
import Combine
struct NumbersOnlyViewModifier: ViewModifier {
#Binding var text: String
var includeDecimal: Bool
var digitAllowedAfterDecimal: Int = 1
func body(content: Content) -> some View {
content
.keyboardType(includeDecimal ? .decimalPad : .numberPad)
.onReceive(Just(text)) { newValue in
var numbers = "0123456789"
let decimalSeparator: String = Locale.current.decimalSeparator ?? "."
if includeDecimal {
numbers += decimalSeparator
}
if newValue.components(separatedBy: decimalSeparator).count-1 > 1 {
let filtered = newValue
self.text = isValid(newValue: String(filtered.dropLast()), decimalSeparator: decimalSeparator)
} else {
let filtered = newValue.filter { numbers.contains($0)}
if filtered != newValue {
self.text = isValid(newValue: filtered, decimalSeparator: decimalSeparator)
} else {
self.text = isValid(newValue: newValue, decimalSeparator: decimalSeparator)
}
}
}
}
private func isValid(newValue: String, decimalSeparator: String) -> String {
guard includeDecimal, !text.isEmpty else { return newValue }
let component = newValue.components(separatedBy: decimalSeparator)
if component.count > 1 {
guard let last = component.last else { return newValue }
if last.count > digitAllowedAfterDecimal {
let filtered = newValue
return String(filtered.dropLast())
}
}
return newValue
}
}
File View+Extenstion
extension View {
func numbersOnly(_ text: Binding<String>, includeDecimal: Bool = false) -> some View {
self.modifier(NumbersOnlyViewModifier(text: text, includeDecimal: includeDecimal))
}
}
File ViewFile
TextField("", text: $value, onEditingChanged: { isEditing in
self.isEditing = isEditing
})
.foregroundColor(Color.neutralGray900)
.numbersOnly($value, includeDecimal: true)
.font(.system(size: Constants.FontSizes.fontSize22))
.multilineTextAlignment(.center)