diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index 54eb1514..83aff352 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -67,6 +67,7 @@ EA985C672970C21600F2FF2E /* VDSLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA985C662970C21600F2FF2E /* VDSLayout.swift */; }; EA985C692971B90B00F2FF2E /* IconSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA985C682971B90B00F2FF2E /* IconSize.swift */; }; EA985C7D297DAED300F2FF2E /* Primitive.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA985C7C297DAED300F2FF2E /* Primitive.swift */; }; + EA985C8E2983377600F2FF2E /* ImageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA985C8D2983377600F2FF2E /* ImageManager.swift */; }; EAA5EEB528ECBFB4003B3210 /* ImageLabelAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA5EEB428ECBFB4003B3210 /* ImageLabelAttribute.swift */; }; EAA5EEB728ECC03A003B3210 /* ToolTipLabelAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA5EEB628ECC03A003B3210 /* ToolTipLabelAttribute.swift */; }; EAA5EEB928ECD24B003B3210 /* Icons.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EAA5EEB828ECD24B003B3210 /* Icons.xcassets */; }; @@ -183,6 +184,7 @@ EA985C662970C21600F2FF2E /* VDSLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VDSLayout.swift; sourceTree = ""; }; EA985C682971B90B00F2FF2E /* IconSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSize.swift; sourceTree = ""; }; EA985C7C297DAED300F2FF2E /* Primitive.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Primitive.swift; sourceTree = ""; }; + EA985C8D2983377600F2FF2E /* ImageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageManager.swift; sourceTree = ""; }; EAA5EEB428ECBFB4003B3210 /* ImageLabelAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLabelAttribute.swift; sourceTree = ""; }; EAA5EEB628ECC03A003B3210 /* ToolTipLabelAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolTipLabelAttribute.swift; sourceTree = ""; }; EAA5EEB828ECD24B003B3210 /* Icons.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Icons.xcassets; sourceTree = ""; }; @@ -315,6 +317,7 @@ EA33619D288B1E330071C351 /* Components */, EA3361A6288B23240071C351 /* Extensions */, EA3361DF2891D0F10071C351 /* Fonts */, + EA985C8C2983376100F2FF2E /* Managers */, EA3361AB288B25EC0071C351 /* Protocols */, EAB1D2E228AE842000DAE764 /* Publishers */, EAB1D2D028ABEF3100DAE764 /* Typography */, @@ -415,7 +418,6 @@ EA3361B4288B2A360071C351 /* Classes */ = { isa = PBXGroup; children = ( - EA985C1C296CD13600F2FF2E /* BundleManager.swift */, EAF7F0B8289C139800B287F5 /* ColorConfiguration.swift */, EAF7F09D289AAEC000B287F5 /* Constants.swift */, EA3361B5288B2A410071C351 /* Control.swift */, @@ -541,6 +543,15 @@ path = TextArea; sourceTree = ""; }; + EA985C8C2983376100F2FF2E /* Managers */ = { + isa = PBXGroup; + children = ( + EA985C1C296CD13600F2FF2E /* BundleManager.swift */, + EA985C8D2983377600F2FF2E /* ImageManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; EAB1D2D028ABEF3100DAE764 /* Typography */ = { isa = PBXGroup; children = ( @@ -804,6 +815,7 @@ EAF7F0A2289AFB3900B287F5 /* Errorable.swift in Sources */, EA985C7D297DAED300F2FF2E /* Primitive.swift in Sources */, EAB5FEF829393A7200998C17 /* ButtonGroupConstants.swift in Sources */, + EA985C8E2983377600F2FF2E /* ImageManager.swift in Sources */, EA3361AF288B26310071C351 /* FormFieldable.swift in Sources */, EA5E3058295105A40082B959 /* Tilelet.swift in Sources */, EA5E304E294CC7F00082B959 /* VDSColor.swift in Sources */, diff --git a/VDS/Components/Label/Attributes/ActionLabelAttribute.swift b/VDS/Components/Label/Attributes/ActionLabelAttribute.swift index 81f4981f..3ab19c60 100644 --- a/VDS/Components/Label/Attributes/ActionLabelAttribute.swift +++ b/VDS/Components/Label/Attributes/ActionLabelAttribute.swift @@ -49,9 +49,13 @@ public struct ActionLabelAttribute: ActionLabelAttributeModel { case location, length } - public func setAttribute(on attributedString: NSMutableAttributedString) { - if(shouldUnderline){ - UnderlineLabelAttribute(location: location, length: length).setAttribute(on: attributedString) + public func setAttribute(on attributedString: NSMutableAttributedString) {} +} + +extension Label { + internal func apply(attribute: ActionLabelAttribute, on attributedString: NSMutableAttributedString) { + if(attribute.shouldUnderline){ + apply(attribute: .init(location: attribute.location, length: attribute.length), on: attributedString) } } } diff --git a/VDS/Components/Label/Attributes/TextStyleLabelAttribute.swift b/VDS/Components/Label/Attributes/TextStyleLabelAttribute.swift index de40e02c..a2d79a4c 100644 --- a/VDS/Components/Label/Attributes/TextStyleLabelAttribute.swift +++ b/VDS/Components/Label/Attributes/TextStyleLabelAttribute.swift @@ -44,43 +44,45 @@ public struct TextStyleLabelAttribute: LabelAttributeModel { } public func setAttribute(on attributedString: NSMutableAttributedString) { - attributedString.removeAttribute(.font, range: range) - attributedString.removeAttribute(.foregroundColor, range: range) - attributedString.addAttribute(.font, value: textStyle.font, range: range) - attributedString.addAttribute(.foregroundColor, value: textColor, range: range) - setStyleAttributes(attributedString) } - - private func setStyleAttributes(_ attributedString: NSMutableAttributedString) { +} + +extension Label { + internal func apply(attribute: TextStyleLabelAttribute, on attributedString: NSMutableAttributedString){ + attributedString.removeAttribute(.font, range: attribute.range) + attributedString.removeAttribute(.foregroundColor, range: attribute.range) + attributedString.addAttribute(.font, value: attribute.textStyle.font, range: attribute.range) + attributedString.addAttribute(.foregroundColor, value: attribute.textColor, range: attribute.range) + //set letterSpacing - if textStyle.letterSpacing > 0.0 { - attributedString.removeAttribute(.kern, range: range) - attributedString.addAttribute(.kern, value: textStyle.letterSpacing, range: range) + if attribute.textStyle.letterSpacing > 0.0 { + attributedString.removeAttribute(.kern, range: attribute.range) + attributedString.addAttribute(.kern, value: attribute.textStyle.letterSpacing, range: attribute.range) } //set lineHeight - if textStyle.lineHeight > 0.0 { - let lineHeight = textStyle.lineHeight - let adjustment = lineHeight > textStyle.font.lineHeight ? 2.0 : 1.0 - let baselineOffset = (lineHeight - textStyle.font.lineHeight) / 2.0 / adjustment + if attribute.textStyle.lineHeight > 0.0 { + let lineHeight = attribute.textStyle.lineHeight + let adjustment = lineHeight > attribute.textStyle.font.lineHeight ? 2.0 : 1.0 + let baselineOffset = (lineHeight - attribute.textStyle.font.lineHeight) / 2.0 / adjustment let paragraph = NSMutableParagraphStyle().with { $0.maximumLineHeight = lineHeight $0.minimumLineHeight = lineHeight - $0.alignment = textPosition.textAlignment - $0.lineBreakMode = lineBreakMode + $0.alignment = attribute.textPosition.textAlignment + $0.lineBreakMode = attribute.lineBreakMode } - attributedString.removeAttribute(.baselineOffset, range: range) - attributedString.removeAttribute(.paragraphStyle, range: range) - attributedString.addAttribute(.baselineOffset, value: baselineOffset, range: range) - attributedString.addAttribute(.paragraphStyle, value: paragraph, range: range) + attributedString.removeAttribute(.baselineOffset, range: attribute.range) + attributedString.removeAttribute(.paragraphStyle, range: attribute.range) + attributedString.addAttribute(.baselineOffset, value: baselineOffset, range: attribute.range) + attributedString.addAttribute(.paragraphStyle, value: paragraph, range: attribute.range) - } else if textPosition != .left { + } else if attribute.textPosition != .left { let paragraph = NSMutableParagraphStyle().with { - $0.alignment = textPosition.textAlignment - $0.lineBreakMode = lineBreakMode + $0.alignment = attribute.textPosition.textAlignment + $0.lineBreakMode = attribute.lineBreakMode } - attributedString.removeAttribute(.paragraphStyle, range: range) - attributedString.addAttribute(.paragraphStyle, value: paragraph, range: range) + attributedString.removeAttribute(.paragraphStyle, range: attribute.range) + attributedString.addAttribute(.paragraphStyle, value: paragraph, range: attribute.range) } } } diff --git a/VDS/Components/Label/Attributes/UnderlineLabelAttribute.swift b/VDS/Components/Label/Attributes/UnderlineLabelAttribute.swift index c58945a6..38dda224 100644 --- a/VDS/Components/Label/Attributes/UnderlineLabelAttribute.swift +++ b/VDS/Components/Label/Attributes/UnderlineLabelAttribute.swift @@ -52,12 +52,7 @@ public struct UnderlineLabelAttribute: LabelAttributeModel { //-------------------------------------------------- // MARK: - Public Functions //-------------------------------------------------- - public func setAttribute(on attributedString: NSMutableAttributedString) { - attributedString.addAttribute(.underlineStyle, value: underlineValue.rawValue, range: range) - if let color = color { - attributedString.addAttribute(.underlineColor, value: color, range: range) - } - } + public func setAttribute(on attributedString: NSMutableAttributedString) {} } extension UnderlineLabelAttribute { @@ -114,3 +109,12 @@ extension UnderlineLabelAttribute { } } } + +extension Label { + internal func apply(attribute: UnderlineLabelAttribute, on attributedString: NSMutableAttributedString) { + attributedString.addAttribute(.underlineStyle, value: attribute.underlineValue.rawValue, range: attribute.range) + if let color = attribute.color { + attributedString.addAttribute(.underlineColor, value: color, range: attribute.range) + } + } +} diff --git a/VDS/Components/Label/Label.swift b/VDS/Components/Label/Label.swift index 2a4aefd4..32b19170 100644 --- a/VDS/Components/Label/Label.swift +++ b/VDS/Components/Label/Label.swift @@ -131,10 +131,7 @@ public class Label: UILabel, Handlerable, ViewProtocol, Resettable, UserInfoable //create the primary string let startingAttributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: textColor] let mutableText = NSMutableAttributedString(string: text, attributes: startingAttributes) - - //set the local lineHeight/lineSpacing attributes - setStyleAttributes(attributedString: mutableText) - + applyAttributes(mutableText) //set the attributed text @@ -147,21 +144,40 @@ public class Label: UILabel, Handlerable, ViewProtocol, Resettable, UserInfoable private func applyAttributes(_ mutableAttributedString: NSMutableAttributedString) { actions = [] + //apply the base TextStyle to the entire string + var allAttributes: [any LabelAttributeModel] = [ + TextStyleLabelAttribute(location: 0, + length: mutableAttributedString.length, + textStyle: textStyle, + textColor: textColor, + textPosition: textPosition, + lineBreakMode: lineBreakMode), + ] + if let attributes = attributes { - //loop through the models attributes - for attribute in attributes { + allAttributes.append(contentsOf: attributes) + } + + //loop through the models attributes + for attribute in allAttributes { + + //add attribute on the string + attribute.setAttribute(on: mutableAttributedString) + + //see if the attribute is Actionable + if let actionable = attribute as? any ActionLabelAttributeModel{ + //create a accessibleAction + let customAccessibilityAction = customAccessibilityAction(range: actionable.range, accessibleText: actionable.accessibleText) - //add attribute on the string - attribute.setAttribute(on: mutableAttributedString) + //create a wrapper for the attributes range, block and + actions.append(LabelAction(range: actionable.range, action: actionable.action, accessibilityID: customAccessibilityAction?.hashValue ?? -1)) + + } else if let attribute = attribute as? TextStyleLabelAttribute { + apply(attribute: attribute, on: mutableAttributedString) + + } else if let attribute = attribute as? UnderlineLabelAttribute { + apply(attribute: attribute, on: mutableAttributedString) - //see if the attribute is Actionable - if let actionable = attribute as? any ActionLabelAttributeModel{ - //create a accessibleAction - let customAccessibilityAction = customAccessibilityAction(range: actionable.range, accessibleText: actionable.accessibleText) - - //create a wrapper for the attributes range, block and - actions.append(LabelAction(range: actionable.range, action: actionable.action, accessibilityID: customAccessibilityAction?.hashValue ?? -1)) - } } } } diff --git a/VDS/Classes/BundleManager.swift b/VDS/Managers/BundleManager.swift similarity index 100% rename from VDS/Classes/BundleManager.swift rename to VDS/Managers/BundleManager.swift diff --git a/VDS/Managers/ImageManager.swift b/VDS/Managers/ImageManager.swift new file mode 100644 index 00000000..926e6b66 --- /dev/null +++ b/VDS/Managers/ImageManager.swift @@ -0,0 +1,149 @@ +// +// ImageManager.swift +// VDS +// +// Created by Matt Bruce on 1/26/23. +// + +import Foundation +import UIKit.UIImage +import Combine + +// Declares in-memory image cache +public protocol ImageCacheType: class { + // Returns the image associated with a given url + func image(for url: URL) -> UIImage? + // Inserts the image of the specified url in the cache + func insertImage(_ image: UIImage?, for url: URL) + // Removes the image of the specified url in the cache + func removeImage(for url: URL) + // Removes all images from the cache + func removeAllImages() + // Accesses the value associated with the given key for reading and writing + subscript(_ url: URL) -> UIImage? { get set } +} + +public final class ImageCache: ImageCacheType { + + // 1st level cache, that contains encoded images + private lazy var imageCache: NSCache = { + let cache = NSCache() + cache.countLimit = config.countLimit + return cache + }() + // 2nd level cache, that contains decoded images + private lazy var decodedImageCache: NSCache = { + let cache = NSCache() + cache.totalCostLimit = config.memoryLimit + return cache + }() + private let lock = NSLock() + private let config: Config + + public struct Config { + public let countLimit: Int + public let memoryLimit: Int + + public static let defaultConfig = Config(countLimit: 100, memoryLimit: 1024 * 1024 * 100) // 100 MB + } + + public init(config: Config = Config.defaultConfig) { + self.config = config + } + + public func image(for url: URL) -> UIImage? { + lock.lock(); defer { lock.unlock() } + // the best case scenario -> there is a decoded image in memory + if let decodedImage = decodedImageCache.object(forKey: url as AnyObject) as? UIImage { + return decodedImage + } + // search for image data + if let image = imageCache.object(forKey: url as AnyObject) as? UIImage { + let decodedImage = image.decodedImage() + decodedImageCache.setObject(image as AnyObject, forKey: url as AnyObject, cost: decodedImage.diskSize) + return decodedImage + } + return nil + } + + public func insertImage(_ image: UIImage?, for url: URL) { + guard let image = image else { return removeImage(for: url) } + let decompressedImage = image.decodedImage() + + lock.lock(); defer { lock.unlock() } + imageCache.setObject(decompressedImage, forKey: url as AnyObject, cost: 1) + decodedImageCache.setObject(image as AnyObject, forKey: url as AnyObject, cost: decompressedImage.diskSize) + } + + public func removeImage(for url: URL) { + lock.lock(); defer { lock.unlock() } + imageCache.removeObject(forKey: url as AnyObject) + decodedImageCache.removeObject(forKey: url as AnyObject) + } + + public func removeAllImages() { + lock.lock(); defer { lock.unlock() } + imageCache.removeAllObjects() + decodedImageCache.removeAllObjects() + } + + public subscript(_ key: URL) -> UIImage? { + get { + return image(for: key) + } + set { + return insertImage(newValue, for: key) + } + } +} + +fileprivate extension UIImage { + + func decodedImage() -> UIImage { + guard let cgImage = cgImage else { return self } + let size = CGSize(width: cgImage.width, height: cgImage.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let context = CGContext(data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: cgImage.bytesPerRow, space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue) + context?.draw(cgImage, in: CGRect(origin: .zero, size: size)) + guard let decodedImage = context?.makeImage() else { return self } + return UIImage(cgImage: decodedImage) + } + + // Rough estimation of how much memory image uses in bytes + var diskSize: Int { + guard let cgImage = cgImage else { return 0 } + return cgImage.bytesPerRow * cgImage.height + } +} + +public final class ImageLoader { + public static let shared = ImageLoader() + + private let cache: ImageCacheType + private lazy var backgroundQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 5 + return queue + }() + + public init(cache: ImageCacheType = ImageCache()) { + self.cache = cache + } + + public func loadImage(from url: URL) -> AnyPublisher { + if let image = cache[url] { + return Just(image).eraseToAnyPublisher() + } + return URLSession.shared.dataTaskPublisher(for: url) + .map { (data, response) -> UIImage? in return UIImage(data: data) } + .catch { error in return Just(nil) } + .handleEvents(receiveOutput: {[unowned self] image in + guard let image = image else { return } + self.cache[url] = image + }) + .print("Image loading \(url):") + .subscribe(on: backgroundQueue) + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } +}