var str:String = "When I was ###young$$$, I thought ###time$$$ was money"
VStsck{
Text(str)
}
enter image description here
You can parse your string using regular expression and build Text based on found ranges:
struct HighlightedText: View{
let text: Text
private static let regularExpression = try! NSRegularExpression(
pattern: "###(?<content>((?!\\$\\$\\$).)*)\\$\\$\\$"
)
private struct SubstringRange {
let content: NSRange
let full: NSRange
}
init(_ string: String) {
let ranges = Self.regularExpression
.matches(
in: string,
options: [],
range: NSRange(location: 0, length: string.count)
)
.map { match in
SubstringRange(
content: match.range(withName: "content"),
full: match.range(at: 0)
)
}
var nextNotProcessedSymbol = 0
var text = Text("")
let nsString = string as NSString
func appendSubstringStartingNextIfNeeded(until endLocation: Int) {
if nextNotProcessedSymbol < endLocation {
text = text + Text(nsString.substring(
with: NSRange(
location: nextNotProcessedSymbol,
length: endLocation - nextNotProcessedSymbol
)
))
}
}
for range in ranges {
appendSubstringStartingNextIfNeeded(until: range.full.location)
text = text + Text(nsString.substring(with: range.content))
.foregroundColor(Color.red)
nextNotProcessedSymbol = range.full.upperBound
}
appendSubstringStartingNextIfNeeded(until: string.count)
self.text = text
}
var body: some View {
text
}
}
Usage:
HighlightedText("When I was ###young$$$, I thought ###time$$$ was money")
func hilightText(str:String) -> Text{
var resultText:Text = Text("")
if(str.contains("###")){
let titleArr:Array<String> = str.components(spearatedBy:"###")
for(title in titleArr){
resultText = resultText
+ Text(title.components(spearatedBy:"$$$")[0]).foregroundColor(Color.red)
+ Text(title.components(spearatedBy:"$$$")[1]).foregroundColor(Color.gray)
}else{
resultText = resultText + Text(title).foregroundColor(Color.gray)
}
}
return resultText
}else{
return Text(str).foregroundColor(Color.gray)
}
struct View1:View{
#State var title = "When I was ###young$$$, I thought ###time$$$ was money"
var body : some View{
hilightText(str:title)
}
}
Related
I am new to Swiftui and I struggle to understand how to properly retain data created in ObservableObject when rendering views? Or a completely different approach to the problem maybe?
More specifically, it is about getting HTTP data in each row in a List().
Right now, it makes the HTTP call far too often when parent views are rendered, which causes all rows to be reloaded.
The same issue can be found here: Keep reference on view/data model after View update
public class VideoFetcher: ObservableObject {
#Published var video: VideoResponse?
#Published var coverImage: UIImage?
#Published var coverImageLoading = false
#Published var categories: String?
#Published var loading = false
#Published var error = false
func load(mediaItemSlug: String = "", broadcasterSlug: String = "") {
self.loading = true
Video.findBySlug(
mediaItemSlug: mediaItemSlug,
broadcasterSlug: broadcasterSlug,
successCallback: {video -> Void in
self.video = video
self.loading = false
self.setCategories()
self.loadCoverImage()
},
errorCallback: {(error, _) -> Void in
self.loading = false
self.error = true
})
}
func loadCoverImage() {
guard self.video!.coverImageUrl != "" else {
return
}
self.coverImageLoading = true
let downloader = ImageDownloader()
let urlRequest = URLRequest(url: URL(string: self.video!.coverImageUrl)!)
let filter = AspectScaledToFillSizeFilter(size: CGSize(width: 520.0, height: 292.499999963))
downloader.download(urlRequest, filter: filter) { response in
if case .success(let image) = response.result {
self.coverImage = image
self.coverImageLoading = false
}
}
}
func setCategories() {
if (self.video!.broadcaster.categories.count > 0) {
let categoryNames = self.video!.broadcaster.categories.map { category in
return category.name == "" ? "(no name)" : category.name
}
self.categories = categoryNames.joined(separator: " • ");
}
}
}
List() row:
struct VideoCard: View {
#ObservedObject var fetcher = VideoFetcher()
...
init() {
// Causes reload each render
self.fetcher.load()
}
var body: some View {
...
.onAppear {
// Loads that on appear but fetcher.video is nil after view re-rendered because load() wasn't called
self.fetcher.load()
}
}
}
Thanks, Chris. I thought I was doing something wrong on an architectural level but I added caching and that solved my problem.
import Alamofire
import AlamofireImage
import Cache
public class VideoFetcher: ObservableObject {
#Published var video: VideoResponse?
#Published var coverImage: UIImage?
#Published var coverImageLoading = false
#Published var broadcasterImage: UIImage?
#Published var categories: String?
#Published var loading = false
#Published var error = false
func load(mediaItemSlug: String = "", broadcasterSlug: String = "") {
let videoCache = try? AppCache.video!.object(forKey: mediaItemSlug)
if (videoCache != nil) {
self.video = videoCache
self.setCategories()
self.loadCoverImage()
return
}
self.loading = true
Video.findBySlug(
mediaItemSlug: mediaItemSlug,
broadcasterSlug: broadcasterSlug,
successCallback: {video -> Void in
try? AppCache.video!.setObject(video, forKey: mediaItemSlug)
self.video = video
self.loading = false
self.setCategories()
self.loadCoverImage()
self.loadBroadcasterImage()
},
errorCallback: {(error, _) -> Void in
self.loading = false
self.error = true
})
}
func loadCoverImage() {
let coverImageUrl = self.video!.coverImageUrl
guard coverImageUrl != "" else {
return
}
let urlRequest = URLRequest(url: URL(string: coverImageUrl)!)
let cachedImage = AppCache.image!.image(for: urlRequest, withIdentifier: coverImageUrl)
if (cachedImage != nil) {
self.coverImage = cachedImage
return
}
self.coverImageLoading = true
let downloader = ImageDownloader(imageCache: AppCache.image!)
let filter = AspectScaledToFillSizeFilter(size: CGSize(width: 520.0, height: 292.499999963))
downloader.download(urlRequest, filter: filter) { response in
if case .success(let image) = response.result {
AppCache.image!.add(image, for: urlRequest, withIdentifier: coverImageUrl)
self.coverImage = image
self.coverImageLoading = false
}
}
}
func loadBroadcasterImage() {
let broadcasterImage = self.video!.broadcaster.avatarImageUrl
guard broadcasterImage != "" else {
return
}
let urlRequest = URLRequest(url: URL(string: broadcasterImage)!)
let cachedImage = AppCache.image!.image(for: urlRequest, withIdentifier: broadcasterImage)
if (cachedImage != nil) {
self.broadcasterImage = cachedImage
return
}
let downloader = ImageDownloader(imageCache: AppCache.image!)
let filter = AspectScaledToFillSizeFilter(size: CGSize(width: 16, height: 16))
downloader.download(urlRequest, filter: filter) { response in
if case .success(var image) = response.result {
image = image.af.imageRoundedIntoCircle()
AppCache.image!.add(image, for: urlRequest, withIdentifier: broadcasterImage)
self.broadcasterImage = image
}
}
}
func setCategories() {
let categories = self.video!.broadcaster.categories
if (categories.count > 0) {
let categoryNames = categories.map { category in
return category.name == "" ? "(no name)" : category.name
}
self.categories = categoryNames.joined(separator: " • ");
}
}
}
I have the following classes
class ListItem: Identifiable {
var id: UUID
var name: String
var description: String
var isFavorite: Bool
var debugDescription: String {
return "Name: \(self.name) | Favorite?: \(self.isFavorite)"
}
public init(name: String) {
self.name = name
id = UUID()
self.description = "Some text describing why \(self.name.lowercased()) is awesome"
self.isFavorite = false
}
}
class ListItems: ObservableObject {
#Published var items: [ListItem]
let defaultAnimals = ["Ant", "Bear", "Cat", "Dog", "Elephant",
"Fish", "Giraffe", "Hyena", "Iguana", "Jackal", "Kingfisher", "Leopard", "Monkey"]
public init(animals: [String] = []) {
let animalList: [String] = animals.count > 0 ? animals : defaultAnimals
self.items = animalList.sorted {
$0.lowercased() < $1.lowercased()
}.map {
ListItem(name: $0.firstUppercased)
}
}
}
and the following image view in ContentView
struct ContentView: View {
#ObservedObject var list: ListItems = ListItems()
var body: some View {
List(list.items) {
animal in HStack {
// ...
Image(systemName: animal.isFavorite ? "heart.fill" : "heart").foregroundColor(.pink).onTapGesture {
let index = self.list.items.firstIndex { $0.id == animal.id } ?? -1
if (index >= 0) {
self.list.items[index].isFavorite = !animal.isFavorite
self.list.items = Array(self.list.items[0...self.list.items.count-1]) // <--
}
}
// ...
}
}
}
}
Everytime, the image view is tapped, I am basically reassigning the entire array like this so that the changes can be reflected in the UI
self.list.items = Array(self.list.items[0...self.list.items.count-1])
My question: How can I refactor my code to prevent reassigning the entire object array every time some object property changes?
I am fairly new to Swift & iOS development, not sure if I am missing something basic.
Declare ListItem as an struct instead of a class, this way the view will be notified when isFavorite changes. And just a little suggestion; you can use toggle to change the value of a boolean: self.list.items[index].isFavorite.toggle()
I have a UILabel that will vary in number of lines. I'm using a custom font, and want to set the line height of this label to something >1.
I'm not really familiar with KVO, but I use Rx, so I can suggest using this
import UIKit
import RxSwift
import RxCocoa
extension UILabel {
private func setLineHeight(lineHeight: CGFloat) {
var attributeStringInitial: NSMutableAttributedString?
var textInitial: String?
if let text_ = self.text {
attributeStringInitial = NSMutableAttributedString(string: text_)
textInitial = text_
} else if let text_ = self.attributedText {
attributeStringInitial = NSMutableAttributedString(attributedString: text_)
textInitial = text_.string
}
guard let attributeString = attributeStringInitial,
let text = textInitial
else { return }
let style = NSMutableParagraphStyle()
style.lineSpacing = lineHeight
attributeString.addAttribute(NSAttributedString.Key.paragraphStyle, value: style, range: NSMakeRange(0, text.count))
self.attributedText = attributeString
}
func setLineHeight(lineHeight: CGFloat, disposeBag: DisposeBag) {
rx.observe(String.self, "text")
.subscribe(onNext: { [weak self] text in
self?.setLineHeight(lineHeight: 10)
})
.disposed(by: disposeBag)
}
}
I am trying to make a numbered list out of the information the user inputs into a UITextView. For example,
List item one
List item two
List item three
Here is the code that I have tried but does not give me the desired effect.
var currentLine: Int = 1
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// Add "1" when the user starts typing into the text field
if (textView.text.isEmpty && !text.isEmpty) {
textView.text = "\(currentLine). "
currentLine += 1
}
else {
if text.isEmpty {
if textView.text.characters.count >= 4 {
let str = textView.text.substring(from:textView.text.index(textView.text.endIndex, offsetBy: -4))
if str.hasPrefix("\n") {
textView.text = String(textView.text.characters.dropLast(3))
currentLine -= 1
}
}
else if text.isEmpty && textView.text.characters.count == 3 {
textView.text = String(textView.text.characters.dropLast(3))
currentLine = 1
}
}
else {
let str = textView.text.substring(from:textView.text.index(textView.text.endIndex, offsetBy: -1))
if str == "\n" {
textView.text = "\(textView.text!)\(currentLine). "
currentLine += 1
}
}
}
return true
}
as suggested here: How to make auto numbering on UITextview when press return key in swift but had no success.
Any help is much appreciated.
You can subclass UITextView, override method willMove(toSuperview and add an observer for UITextViewTextDidChange with a selector that break up your text into lines enumerating and numbering it accordingly. Try like this:
class NumberedTextView: UITextView {
override func willMove(toSuperview newSuperview: UIView?) {
frame = newSuperview?.frame.insetBy(dx: 50, dy: 80) ?? frame
backgroundColor = .lightGray
NotificationCenter.default.addObserver(self, selector: #selector(textViewDidChange), name: .UITextViewTextDidChange, object: nil)
}
func textViewDidChange(notification: Notification) {
var lines: [String] = []
for (index, line) in text.components(separatedBy: .newlines).enumerated() {
if !line.hasPrefix("\(index.advanced(by: 1))") &&
!line.trimmingCharacters(in: .whitespaces).isEmpty {
lines.append("\(index.advanced(by: 1)). " + line)
} else {
lines.append(line)
}
}
text = lines.joined(separator: "\n")
// this prevents two empty lines at the bottom
if text.hasSuffix("\n\n") {
text = String(text.characters.dropLast())
}
}
}
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let textView = NumberedTextView()
view.addSubview(textView)
}
}
So the method I used in Swift 2 no longer works because of changes in Swift 3, regarding String indices and ranges. Previously I had
func configureLabel(defaultColor: UIColor, highlightColor: UIColor, boldKeyText: Bool) {
if let index = self.text?.characters.indexOf(Character("|")) {
self.text = self.text!.stringByReplacingOccurrencesOfString("|", withString: "")
let labelLength:Int = Int(String(index))! // Now returns nil
var keyAttr: [String:AnyObject] = [NSForegroundColorAttributeName: highlightColor]
var valAttr: [String:AnyObject] = [NSForegroundColorAttributeName: defaultColor]
if boldKeyText {
keyAttr[NSFontAttributeName] = UIFont.systemFontOfSize(self.font.pointSize)
valAttr[NSFontAttributeName] = UIFont.systemFontOfSize(self.font.pointSize, weight: UIFontWeightHeavy)
}
let attributeString = NSMutableAttributedString(string: self.text!)
attributeString.addAttributes(keyAttr, range: NSRange(location: 0, length: (self.text?.characters.count)!))
attributeString.addAttributes(valAttr, range: NSRange(location: 0, length: labelLength))
self.attributedText = attributeString
}
}
Basically I would be able to take a string like "First Name:| Gary Oak" and have all the parts before and after the | character be different colors, or make part of it bold, but the line I commented above no longer returns a value, which breaks everything else afterwards. Any ideas on how to do this?
In Swift 3 you can use something like this:
func configureLabel(defaultColor: UIColor, highlightColor: UIColor, boldKeyText: Bool) {
if let index = self.text?.characters.index(of: Character("|")) {
self.text = self.text!.replacingOccurrences(of: "|", with: "")
let position = text.distance(from: text.startIndex, to: index)
let labelLength:Int = Int(String(describing: position))!
var keyAttr: [String:AnyObject] = [NSForegroundColorAttributeName: defaultColor]
var valAttr: [String:AnyObject] = [NSForegroundColorAttributeName: highlightColor]
if boldKeyText {
keyAttr[NSFontAttributeName] = UIFont.systemFont(ofSize: self.font.pointSize)
valAttr[NSFontAttributeName] = UIFont.systemFont(ofSize: self.font.pointSize, weight: UIFontWeightHeavy)
}
let attributeString = NSMutableAttributedString(string: self.text!)
attributeString.addAttributes(keyAttr, range: NSRange(location: 0, length: (self.text?.characters.count)!))
attributeString.addAttributes(valAttr, range: NSRange(location: 0, length: labelLength))
self.attributedText = attributeString
}
}
the main idea that using let position = text.distance(from: text.startIndex, to: index) you got not the integer representation of string position but the string Index value. Using text.distance(from: text.startIndex, to: index) you can find int position for string Index