From cfd5dff9e3a17ce1f4ea15ed556290e4f77350cb Mon Sep 17 00:00:00 2001 From: Roberto Zunica Date: Thu, 11 Dec 2025 23:59:26 +0100 Subject: [PATCH 1/4] added text tags icons not working yet --- QVRWeekView/Assets/tags/bed.svg | 3 + QVRWeekView/Assets/tags/fail.svg | 3 + QVRWeekView/Assets/tags/success.svg | 3 + QVRWeekView/Classes/Common/EventData.swift | 27 +++- QVRWeekView/Classes/Common/EventLayer.swift | 151 +++++++++++++++++++- 5 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 QVRWeekView/Assets/tags/bed.svg create mode 100644 QVRWeekView/Assets/tags/fail.svg create mode 100644 QVRWeekView/Assets/tags/success.svg diff --git a/QVRWeekView/Assets/tags/bed.svg b/QVRWeekView/Assets/tags/bed.svg new file mode 100644 index 0000000..c3ee004 --- /dev/null +++ b/QVRWeekView/Assets/tags/bed.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/QVRWeekView/Assets/tags/fail.svg b/QVRWeekView/Assets/tags/fail.svg new file mode 100644 index 0000000..9b95147 --- /dev/null +++ b/QVRWeekView/Assets/tags/fail.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/QVRWeekView/Assets/tags/success.svg b/QVRWeekView/Assets/tags/success.svg new file mode 100644 index 0000000..3c44a2b --- /dev/null +++ b/QVRWeekView/Assets/tags/success.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/QVRWeekView/Classes/Common/EventData.swift b/QVRWeekView/Classes/Common/EventData.swift index c691310..a6ece7e 100644 --- a/QVRWeekView/Classes/Common/EventData.swift +++ b/QVRWeekView/Classes/Common/EventData.swift @@ -26,6 +26,8 @@ open class EventData: NSObject, NSCoding { public let color: UIColor // Stores if event is an all day event public let allDay: Bool + // Tags associated with the event + public let tags: [String] // Stores an optional gradient layer which will be used to draw event. Can only be set once. private(set) var gradientLayer: CAGradientLayer? { didSet { gradientLayer = oldValue ?? gradientLayer } } @@ -37,12 +39,13 @@ open class EventData: NSObject, NSCoding { /** Main initializer. All properties. */ - public init(id: String, title: String, startDate: Date, endDate: Date, location: String, color: UIColor, allDay: Bool, gradientLayer: CAGradientLayer? = nil) { + public init(id: String, title: String, startDate: Date, endDate: Date, location: String, color: UIColor, allDay: Bool, tags: [String] = [], gradientLayer: CAGradientLayer? = nil) { self.id = id self.title = title self.location = location self.color = color self.allDay = allDay + self.tags = tags guard startDate.compare(endDate).rawValue <= 0 else { self.startDate = startDate self.endDate = startDate @@ -119,6 +122,7 @@ open class EventData: NSObject, NSCoding { coder.encode(location, forKey: EventDataEncoderKey.location) coder.encode(color, forKey: EventDataEncoderKey.color) coder.encode(allDay, forKey: EventDataEncoderKey.allDay) + coder.encode(tags, forKey: EventDataEncoderKey.tags) coder.encode(gradientLayer, forKey: EventDataEncoderKey.gradientLayer) } @@ -131,6 +135,7 @@ open class EventData: NSObject, NSCoding { let dColor = coder.decodeObject(forKey: EventDataEncoderKey.color) as? UIColor { let dGradientLayer = coder.decodeObject(forKey: EventDataEncoderKey.gradientLayer) as? CAGradientLayer let dAllDay = coder.decodeBool(forKey: EventDataEncoderKey.allDay) + let dTags = coder.decodeObject(forKey: EventDataEncoderKey.tags) as? [String] ?? [] self.init(id: dId, title: dTitle, startDate: dStartDate, @@ -138,6 +143,7 @@ open class EventData: NSObject, NSCoding { location: dLocation, color: dColor, allDay: dAllDay, + tags: dTags, gradientLayer: dGradientLayer) } else { return nil @@ -152,7 +158,8 @@ open class EventData: NSObject, NSCoding { (lhs.title == rhs.title) && (lhs.location == rhs.location) && (lhs.allDay == rhs.allDay) && - (lhs.color.isEqual(rhs.color)) + (lhs.color.isEqual(rhs.color)) && + (lhs.tags == rhs.tags) } public override var hash: Int { @@ -167,8 +174,13 @@ open class EventData: NSObject, NSCoding { open func getDisplayString(withMainFont mainFont: UIFont, infoFont: UIFont, andColor color: UIColor) -> NSAttributedString { let df = DateFormatter() df.dateFormat = "HH:mm" - let mainFontAttributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: mainFont, NSAttributedString.Key.foregroundColor: color.cgColor] - let infoFontAttributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: infoFont, NSAttributedString.Key.foregroundColor: color.cgColor] + + // Use Montserrat Bold for title, Montserrat Medium for description + let titleFont = UIFont(name: "Montserrat-Bold", size: 12) ?? UIFont.boldSystemFont(ofSize: 12) + let descFont = UIFont(name: "Montserrat-Medium", size: 10) ?? UIFont.systemFont(ofSize: 10, weight: .medium) + + let mainFontAttributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: titleFont, NSAttributedString.Key.foregroundColor: UIColor.white] + let infoFontAttributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: descFont, NSAttributedString.Key.foregroundColor: UIColor.white] let mainAttributedString = NSMutableAttributedString(string: self.title, attributes: mainFontAttributes) if !self.allDay { mainAttributedString.append(NSMutableAttributedString( @@ -204,19 +216,19 @@ open class EventData: NSObject, NSCoding { } public func remakeEventData(withStart start: Date, andEnd end: Date) -> EventData { - let newEvent = EventData(id: self.id, title: self.title, startDate: start, endDate: end, location: self.location, color: self.color, allDay: self.allDay) + let newEvent = EventData(id: self.id, title: self.title, startDate: start, endDate: end, location: self.location, color: self.color, allDay: self.allDay, tags: self.tags) newEvent.configureGradient(self.gradientLayer) return newEvent } public func remakeEventData(withColor color: UIColor) -> EventData { - let newEvent = EventData(id: self.id, title: self.title, startDate: self.startDate, endDate: self.endDate, location: self.location, color: color, allDay: self.allDay) + let newEvent = EventData(id: self.id, title: self.title, startDate: self.startDate, endDate: self.endDate, location: self.location, color: color, allDay: self.allDay, tags: self.tags) newEvent.configureGradient(self.gradientLayer) return newEvent } public func remakeEventDataAsAllDay(forDate date: Date) -> EventData { - let newEvent = EventData(id: self.id, title: self.title, startDate: date.getStartOfDay(), endDate: date.getEndOfDay(), location: self.location, color: self.color, allDay: true) + let newEvent = EventData(id: self.id, title: self.title, startDate: date.getStartOfDay(), endDate: date.getEndOfDay(), location: self.location, color: self.color, allDay: true, tags: self.tags) newEvent.configureGradient(self.gradientLayer) return newEvent } @@ -297,5 +309,6 @@ struct EventDataEncoderKey { static let location = "EVENT_DATA_LOCATION" static let color = "EVENT_DATA_COLOR" static let allDay = "EVENT_DATA_ALL_DAY" + static let tags = "EVENT_DATA_TAGS" static let gradientLayer = "EVENT_DATA_GRADIENT_LAYER" } diff --git a/QVRWeekView/Classes/Common/EventLayer.swift b/QVRWeekView/Classes/Common/EventLayer.swift index 099f6ef..ba49f1c 100644 --- a/QVRWeekView/Classes/Common/EventLayer.swift +++ b/QVRWeekView/Classes/Common/EventLayer.swift @@ -25,6 +25,9 @@ class EventLayer: CALayer { self.backgroundColor = event.color.cgColor } + let xPadding = layout.eventLabelHorizontalTextPadding + let yPadding = layout.eventLabelVerticalTextPadding + // Configure event text layer let eventTextLayer = CATextLayer() eventTextLayer.isWrapped = true @@ -33,15 +36,157 @@ class EventLayer: CALayer { withMainFont: layout.eventLabelFont, infoFont: layout.eventLabelInfoFont, andColor: layout.eventLabelTextColor) - - let xPadding = layout.eventLabelHorizontalTextPadding - let yPadding = layout.eventLabelVerticalTextPadding + eventTextLayer.frame = CGRect( x: frame.origin.x + xPadding, y: frame.origin.y + yPadding, width: frame.width - 2 * xPadding, height: frame.height - 2 * yPadding) self.addSublayer(eventTextLayer) + + // Add tags at the bottom if available + if !event.tags.isEmpty { + let tagHeight: CGFloat = 18 + let bottomMargin: CGFloat = 4 + let tagsY = frame.origin.y + frame.height - yPadding - tagHeight - bottomMargin + + // Only render tags if there's enough space + if tagsY > frame.origin.y + yPadding + 20 { + addTagsLayers( + tags: event.tags, + x: frame.origin.x + xPadding, + y: tagsY, + maxWidth: frame.width - 2 * xPadding, + tagHeight: tagHeight, + eventColor: event.color) + } + } + } + + private func addTagsLayers(tags: [String], x: CGFloat, y: CGFloat, maxWidth: CGFloat, tagHeight: CGFloat, eventColor: UIColor) { + let tagSpacing: CGFloat = 4 + let tagPadding: CGFloat = 6 + let tagCornerRadius: CGFloat = tagHeight / 2 + let iconSize: CGFloat = tagHeight // Icons same height as pills + + var currentX: CGFloat = x + + for tag in tags { + let tagLower = tag.lowercased() + let iconName = getIconForTag(tagLower) + var iconImage: UIImage? = nil + + // Try to load icon if it exists + if let iconName = iconName { + iconImage = loadIconImage(named: iconName) + } + + // Calculate tag width + var tagWidth: CGFloat + let tagFont = UIFont(name: "Montserrat-Medium", size: 10) ?? UIFont.systemFont(ofSize: 10, weight: .medium) + + if iconImage != nil { + tagWidth = iconSize // Just icon width, no padding + } else { + // Text only tags with padding (fallback when icon not found) + let tagText = tag as NSString + let textWidth = tagText.size(withAttributes: [.font: tagFont]).width + tagWidth = textWidth + (tagPadding * 2) + } + + // Check if tag fits on current line + if currentX + tagWidth > x + maxWidth { + break // Stop if doesn't fit + } + + if let image = iconImage { + // Create icon layer from Assets (no background pill) + let iconLayer = CALayer() + iconLayer.contents = image.cgImage + iconLayer.frame = CGRect( + x: currentX, + y: y, + width: iconSize, + height: iconSize + ) + iconLayer.contentsGravity = .resizeAspect + // Use destination out blend mode for transparent icons + iconLayer.compositingFilter = "destinationOut" + self.addSublayer(iconLayer) + } else { + // Create tag background layer (white pill for text fallback) + let tagBackgroundLayer = CALayer() + tagBackgroundLayer.frame = CGRect(x: currentX, y: y, width: tagWidth, height: tagHeight) + tagBackgroundLayer.backgroundColor = UIColor.white.cgColor + tagBackgroundLayer.cornerRadius = tagCornerRadius + self.addSublayer(tagBackgroundLayer) + + // Create tag text layer with Montserrat Medium + let tagTextLayer = CATextLayer() + let tagText = tag as NSString + let textWidth = tagText.size(withAttributes: [.font: tagFont]).width + + tagTextLayer.frame = CGRect( + x: currentX + tagPadding, + y: y + 3, + width: textWidth, + height: tagHeight - 6 + ) + tagTextLayer.string = tag + tagTextLayer.font = tagFont + tagTextLayer.fontSize = 10 + tagTextLayer.foregroundColor = eventColor.cgColor + tagTextLayer.contentsScale = UIScreen.main.scale + tagTextLayer.alignmentMode = .center + self.addSublayer(tagTextLayer) + } + + // Move x position for next tag + currentX += tagWidth + tagSpacing + } + } + + private func getIconForTag(_ tag: String) -> String? { + switch tag { + case "bed": + return "bed" + case "alert": + return "alert" + case "fail": + return "fail" + case "success": + return "success" + case "drink": + return "drink" + default: + return nil + } + } + + private func loadIconImage(named: String) -> UIImage? { + let bundle = Bundle(for: EventLayer.self) + + // Try SVG first + if let svgPath = bundle.path(forResource: named, ofType: "svg", inDirectory: "Assets/tags"), + let svgData = try? Data(contentsOf: URL(fileURLWithPath: svgPath)) { + if #available(iOS 13.0, *), let image = UIImage(data: svgData) { + return image + } + } + + // Try PNG + if let pngPath = bundle.path(forResource: named, ofType: "png", inDirectory: "Assets/tags"), + let image = UIImage(contentsOfFile: pngPath) { + return image + } + + // Try PDF + if let pdfPath = bundle.path(forResource: named, ofType: "pdf", inDirectory: "Assets/tags"), + let image = UIImage(contentsOfFile: pdfPath) { + return image + } + + return nil } required init?(coder aDecoder: NSCoder) { From 2d5243c615d91c9b9a13ae777a5f934c7ffcbd5b Mon Sep 17 00:00:00 2001 From: Roberto Zunica Date: Sun, 14 Dec 2025 17:14:51 +0100 Subject: [PATCH 2/4] added automatic image search for tag icons --- QVRWeekView/Assets/.gitkeep | 0 QVRWeekView/Assets/tags/bed.svg | 3 -- QVRWeekView/Assets/tags/fail.svg | 3 -- QVRWeekView/Assets/tags/success.svg | 3 -- QVRWeekView/Classes/Common/EventLayer.swift | 51 ++++++--------------- README.md | 45 ++++++++++++++++++ 6 files changed, 60 insertions(+), 45 deletions(-) delete mode 100644 QVRWeekView/Assets/.gitkeep delete mode 100644 QVRWeekView/Assets/tags/bed.svg delete mode 100644 QVRWeekView/Assets/tags/fail.svg delete mode 100644 QVRWeekView/Assets/tags/success.svg diff --git a/QVRWeekView/Assets/.gitkeep b/QVRWeekView/Assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/QVRWeekView/Assets/tags/bed.svg b/QVRWeekView/Assets/tags/bed.svg deleted file mode 100644 index c3ee004..0000000 --- a/QVRWeekView/Assets/tags/bed.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/QVRWeekView/Assets/tags/fail.svg b/QVRWeekView/Assets/tags/fail.svg deleted file mode 100644 index 9b95147..0000000 --- a/QVRWeekView/Assets/tags/fail.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/QVRWeekView/Assets/tags/success.svg b/QVRWeekView/Assets/tags/success.svg deleted file mode 100644 index 3c44a2b..0000000 --- a/QVRWeekView/Assets/tags/success.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/QVRWeekView/Classes/Common/EventLayer.swift b/QVRWeekView/Classes/Common/EventLayer.swift index ba49f1c..b751e4d 100644 --- a/QVRWeekView/Classes/Common/EventLayer.swift +++ b/QVRWeekView/Classes/Common/EventLayer.swift @@ -73,13 +73,9 @@ class EventLayer: CALayer { for tag in tags { let tagLower = tag.lowercased() - let iconName = getIconForTag(tagLower) - var iconImage: UIImage? = nil - // Try to load icon if it exists - if let iconName = iconName { - iconImage = loadIconImage(named: iconName) - } + // Try to load icon for any tag (automatically detects from Images.xcassets/tags/) + let iconImage = loadIconImage(named: tagLower) // Calculate tag width var tagWidth: CGFloat @@ -146,43 +142,26 @@ class EventLayer: CALayer { } } - private func getIconForTag(_ tag: String) -> String? { - switch tag { - case "bed": - return "bed" - case "alert": - return "alert" - case "fail": - return "fail" - case "success": - return "success" - case "drink": - return "drink" - default: - return nil - } - } - private func loadIconImage(named: String) -> UIImage? { - let bundle = Bundle(for: EventLayer.self) + // Try to load from main app bundle under tags namespace (Images.xcassets/tags/) + if let image = UIImage(named: "tags/\(named)", in: Bundle.main, compatibleWith: nil) { + return image + } - // Try SVG first - if let svgPath = bundle.path(forResource: named, ofType: "svg", inDirectory: "Assets/tags"), - let svgData = try? Data(contentsOf: URL(fileURLWithPath: svgPath)) { - if #available(iOS 13.0, *), let image = UIImage(data: svgData) { - return image - } + // Try without namespace in main bundle + if let image = UIImage(named: named, in: Bundle.main, compatibleWith: nil) { + return image } - // Try PNG - if let pngPath = bundle.path(forResource: named, ofType: "png", inDirectory: "Assets/tags"), - let image = UIImage(contentsOfFile: pngPath) { + // Try from framework bundle under tags namespace + let bundle = Bundle(for: EventLayer.self) + + if let image = UIImage(named: "tags/\(named)", in: bundle, compatibleWith: nil) { return image } - // Try PDF - if let pdfPath = bundle.path(forResource: named, ofType: "pdf", inDirectory: "Assets/tags"), - let image = UIImage(contentsOfFile: pdfPath) { + // Try without namespace in framework bundle + if let image = UIImage(named: named, in: bundle, compatibleWith: nil) { return image } diff --git a/README.md b/README.md index 8008507..8da28b8 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,51 @@ Below is a table of all customizable properties of the `WeekView` | velocityOffsetMultiplier:`CGFloat` | Sensitivity for horizontal scrolling. A higher number will multiply input velocity more and thus result in more cells being skipped when scrolling. | `0.75` | | horizontalScrolling:`HorizontalScrolling` | Used to determine horizontal scrolling behaviour. `.infinite` is infinite scrolling, `.finite(number, startDate)` is finite scrolling for a given number of days from the starting date. | `.infinite` +### Event Tags + +Events support tags which are displayed at the bottom of event cells. Tags can be text labels or icons. + +#### Using Tags + +Add tags to events by passing a string array: + +```swift +let event = EventData( + id: "1", + title: "Meeting", + startDate: startDate, + endDate: endDate, + location: "Room 101", + color: .blue, + allDay: false, + tags: ["Work", "Important"] +) +``` + +#### Custom Tag Icons + +The library includes built-in icon support for: `bed`, `alert`, `fail`, `success`, `drink`. These tags will display as icons instead of text. If you add them to your app's Assets.xcassets + +To add your own custom tag icons: + +1. **Add to your app's Asset Catalog** (Recommended): + - Open your app's `Assets.xcassets` + - Add a new Image Set for each icon (e.g., "meeting", "personal") + - Add PNG or PDF images to the image sets + - Use the image set name as the tag name + +2. **Using PNG/PDF files**: + - Add PNG or PDF files to your app bundle + - Name them to match your tag names (e.g., "meeting.png") + - The library will automatically find and use them + +The library searches for icons in this order: +1. Main app bundle's Asset Catalog +2. Framework bundle's Asset Catalog +3. Framework's Assets/tags directory (PNG/PDF) + +Tags without matching icons will be displayed as text pills with the event color. + ## How it works The main WeekView view is a subclass of UIView. The view layout is retrieved from the WeekView xib file. WeekView contains a top and side bar sub view. The side bar contains an HourSideBarView which displays the hours. WeekView also contains a DayScrollView (UIScrollView subclass) which controls vertical scrolling and also delegates and contains a DayCollectionView (UICollectionView subclass) which controls the horizontal scrolling. DayCollectionView cells are DayViewCells, whose view is generated programtically (due to inefficiencies caused by auto-layout). From 51c05fe4d774ca8a416bc2f3b2c204a750cc5316 Mon Sep 17 00:00:00 2001 From: Roberto Zunica Date: Sun, 14 Dec 2025 17:15:30 +0100 Subject: [PATCH 3/4] update version number --- QVRWeekView.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QVRWeekView.podspec b/QVRWeekView.podspec index ef17c04..72d4778 100644 --- a/QVRWeekView.podspec +++ b/QVRWeekView.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'QVRWeekView' -s.version = '0.14.2' +s.version = '0.15.0' s.summary = 'QVRWeekView is a simple calendar week view with support for horizontal, vertical scrolling and zooming.' s.swift_version = '5' From c3d248e1f6bdca08de5dcf15adfc820ed94cf6cf Mon Sep 17 00:00:00 2001 From: Willem Vanderhaeghe Date: Tue, 3 Feb 2026 13:01:09 +0100 Subject: [PATCH 4/4] progress: update tags to match android behavior --- QVRWeekView/Classes/Common/Constants.swift | 13 +++ .../Classes/Common/Customization.swift | 62 ++++++++++++ .../Classes/Common/DayViewCellLayout.swift | 13 +++ QVRWeekView/Classes/Common/EventData.swift | 51 +++++++--- QVRWeekView/Classes/Common/EventLayer.swift | 96 ++++++++++++++----- QVRWeekView/Classes/Common/Extensions.swift | 2 +- 6 files changed, 203 insertions(+), 34 deletions(-) diff --git a/QVRWeekView/Classes/Common/Constants.swift b/QVRWeekView/Classes/Common/Constants.swift index 7766c2f..3388be6 100644 --- a/QVRWeekView/Classes/Common/Constants.swift +++ b/QVRWeekView/Classes/Common/Constants.swift @@ -119,6 +119,19 @@ struct LayoutDefaults { static let passedWeekendDayViewColor = UIColor(red: 228/255, green: 228/255, blue: 228/255, alpha: 1.0) // Color for today's view cell. static let todayViewColor = defaultDayViewColor + + // MARK: - TAG DEFAULTS - + + // Default tag height + static let tagHeight = CGFloat(18) + // Default tag spacing (horizontal gap between tags) + static let tagSpacing = CGFloat(4) + // Default tag corner radius + static let tagCornerRadius = CGFloat(9) + // Default tag text size + static let tagTextSize = CGFloat(10) + // Default tag vertical margin (distance from bottom of event) + static let tagVerticalMargin = CGFloat(4) } struct NibNames { diff --git a/QVRWeekView/Classes/Common/Customization.swift b/QVRWeekView/Classes/Common/Customization.swift index 41f4862..82776d4 100644 --- a/QVRWeekView/Classes/Common/Customization.swift +++ b/QVRWeekView/Classes/Common/Customization.swift @@ -482,4 +482,66 @@ public extension WeekView { self.dayScrollView.horizontalScrolling = option } } + + // MARK: - TAG CUSTOMIZATION - + + /** + Height of tags in event cells. + */ + @objc var dayViewTagHeight: CGFloat { + get { + return self.dayScrollView.dayViewCellLayout.tagHeight + } + set(height) { + self.dayScrollView.dayViewCellLayout.tagHeight = height + } + } + + /** + Horizontal spacing between tags. + */ + @objc var dayViewTagSpacing: CGFloat { + get { + return self.dayScrollView.dayViewCellLayout.tagSpacing + } + set(spacing) { + self.dayScrollView.dayViewCellLayout.tagSpacing = spacing + } + } + + /** + Corner radius of tags. + */ + @objc var dayViewTagCornerRadius: CGFloat { + get { + return self.dayScrollView.dayViewCellLayout.tagCornerRadius + } + set(radius) { + self.dayScrollView.dayViewCellLayout.tagCornerRadius = radius + } + } + + /** + Font size of tag text. + */ + @objc var dayViewTagTextSize: CGFloat { + get { + return self.dayScrollView.dayViewCellLayout.tagTextSize + } + set(size) { + self.dayScrollView.dayViewCellLayout.tagTextSize = size + } + } + + /** + Vertical margin between tag and bottom of event. + */ + @objc var dayViewTagVerticalMargin: CGFloat { + get { + return self.dayScrollView.dayViewCellLayout.tagVerticalMargin + } + set(margin) { + self.dayScrollView.dayViewCellLayout.tagVerticalMargin = margin + } + } } diff --git a/QVRWeekView/Classes/Common/DayViewCellLayout.swift b/QVRWeekView/Classes/Common/DayViewCellLayout.swift index af08d9e..317f620 100644 --- a/QVRWeekView/Classes/Common/DayViewCellLayout.swift +++ b/QVRWeekView/Classes/Common/DayViewCellLayout.swift @@ -63,4 +63,17 @@ class DayViewCellLayout { var previewEventMinutePrecision: Double = LayoutDefaults.previewEventPrecisionInMinutes { didSet { update?() } } // Height of the preview event in hours. var previewEventHourHeight: Double = LayoutDefaults.previewEventHeightInHours { didSet { update?() } } + + // MARK: - TAG PROPERTIES - + + // Height of tags + var tagHeight: CGFloat = LayoutDefaults.tagHeight { didSet { update?() } } + // Horizontal spacing between tags + var tagSpacing: CGFloat = LayoutDefaults.tagSpacing { didSet { update?() } } + // Corner radius of tags + var tagCornerRadius: CGFloat = LayoutDefaults.tagCornerRadius { didSet { update?() } } + // Font size of tag text + var tagTextSize: CGFloat = LayoutDefaults.tagTextSize { didSet { update?() } } + // Vertical margin between tag and bottom of event + var tagVerticalMargin: CGFloat = LayoutDefaults.tagVerticalMargin { didSet { update?() } } } diff --git a/QVRWeekView/Classes/Common/EventData.swift b/QVRWeekView/Classes/Common/EventData.swift index a6ece7e..4ef530f 100644 --- a/QVRWeekView/Classes/Common/EventData.swift +++ b/QVRWeekView/Classes/Common/EventData.swift @@ -8,6 +8,19 @@ import Foundation +/** + Represents a single tag with its associated color + */ +public struct EventTag { + public let name: String + public let color: UIColor + + public init(name: String, color: UIColor) { + self.name = name + self.color = color + } +} + /** Class event data stores basic data needed by the rest of the code to calculate and draw events in the dayViewCells in the dayScrollView. */ @@ -26,8 +39,8 @@ open class EventData: NSObject, NSCoding { public let color: UIColor // Stores if event is an all day event public let allDay: Bool - // Tags associated with the event - public let tags: [String] + // Tags associated with the event with their colors + public let eventTags: [EventTag] // Stores an optional gradient layer which will be used to draw event. Can only be set once. private(set) var gradientLayer: CAGradientLayer? { didSet { gradientLayer = oldValue ?? gradientLayer } } @@ -39,13 +52,13 @@ open class EventData: NSObject, NSCoding { /** Main initializer. All properties. */ - public init(id: String, title: String, startDate: Date, endDate: Date, location: String, color: UIColor, allDay: Bool, tags: [String] = [], gradientLayer: CAGradientLayer? = nil) { + public init(id: String, title: String, startDate: Date, endDate: Date, location: String, color: UIColor, allDay: Bool, eventTags: [EventTag] = [], gradientLayer: CAGradientLayer? = nil) { self.id = id self.title = title self.location = location self.color = color self.allDay = allDay - self.tags = tags + self.eventTags = eventTags guard startDate.compare(endDate).rawValue <= 0 else { self.startDate = startDate self.endDate = startDate @@ -122,7 +135,11 @@ open class EventData: NSObject, NSCoding { coder.encode(location, forKey: EventDataEncoderKey.location) coder.encode(color, forKey: EventDataEncoderKey.color) coder.encode(allDay, forKey: EventDataEncoderKey.allDay) - coder.encode(tags, forKey: EventDataEncoderKey.tags) + // Encode tags back to arrays for backward compatibility + let tagNames = eventTags.map { $0.name } + let tagColors = eventTags.map { $0.color } + coder.encode(tagNames, forKey: EventDataEncoderKey.tags) + coder.encode(tagColors, forKey: "tagColors") coder.encode(gradientLayer, forKey: EventDataEncoderKey.gradientLayer) } @@ -135,7 +152,14 @@ open class EventData: NSObject, NSCoding { let dColor = coder.decodeObject(forKey: EventDataEncoderKey.color) as? UIColor { let dGradientLayer = coder.decodeObject(forKey: EventDataEncoderKey.gradientLayer) as? CAGradientLayer let dAllDay = coder.decodeBool(forKey: EventDataEncoderKey.allDay) - let dTags = coder.decodeObject(forKey: EventDataEncoderKey.tags) as? [String] ?? [] + let dTagNames = coder.decodeObject(forKey: EventDataEncoderKey.tags) as? [String] ?? [] + let dTagColors = coder.decodeObject(forKey: "tagColors") as? [UIColor] ?? [] + // Reconstruct EventTag objects from decoded arrays + var eventTags: [EventTag] = [] + for i in 0.. EventData { - let newEvent = EventData(id: self.id, title: self.title, startDate: start, endDate: end, location: self.location, color: self.color, allDay: self.allDay, tags: self.tags) + let newEvent = EventData(id: self.id, title: self.title, startDate: start, endDate: end, location: self.location, color: self.color, allDay: self.allDay, eventTags: self.eventTags) newEvent.configureGradient(self.gradientLayer) return newEvent } public func remakeEventData(withColor color: UIColor) -> EventData { - let newEvent = EventData(id: self.id, title: self.title, startDate: self.startDate, endDate: self.endDate, location: self.location, color: color, allDay: self.allDay, tags: self.tags) + let newEvent = EventData(id: self.id, title: self.title, startDate: self.startDate, endDate: self.endDate, location: self.location, color: color, allDay: self.allDay, eventTags: self.eventTags) newEvent.configureGradient(self.gradientLayer) return newEvent } public func remakeEventDataAsAllDay(forDate date: Date) -> EventData { - let newEvent = EventData(id: self.id, title: self.title, startDate: date.getStartOfDay(), endDate: date.getEndOfDay(), location: self.location, color: self.color, allDay: true, tags: self.tags) + let newEvent = EventData(id: self.id, title: self.title, startDate: date.getStartOfDay(), endDate: date.getEndOfDay(), location: self.location, color: self.color, allDay: true, eventTags: self.eventTags) newEvent.configureGradient(self.gradientLayer) return newEvent } diff --git a/QVRWeekView/Classes/Common/EventLayer.swift b/QVRWeekView/Classes/Common/EventLayer.swift index b751e4d..8b3dae6 100644 --- a/QVRWeekView/Classes/Common/EventLayer.swift +++ b/QVRWeekView/Classes/Common/EventLayer.swift @@ -45,47 +45,61 @@ class EventLayer: CALayer { self.addSublayer(eventTextLayer) // Add tags at the bottom if available - if !event.tags.isEmpty { - let tagHeight: CGFloat = 18 - let bottomMargin: CGFloat = 4 + if !event.eventTags.isEmpty { + let tagHeight = layout.tagHeight + let bottomMargin = layout.tagVerticalMargin let tagsY = frame.origin.y + frame.height - yPadding - tagHeight - bottomMargin // Only render tags if there's enough space if tagsY > frame.origin.y + yPadding + 20 { addTagsLayers( - tags: event.tags, + eventTags: event.eventTags, x: frame.origin.x + xPadding, y: tagsY, maxWidth: frame.width - 2 * xPadding, - tagHeight: tagHeight, + layout: layout, eventColor: event.color) } } } - private func addTagsLayers(tags: [String], x: CGFloat, y: CGFloat, maxWidth: CGFloat, tagHeight: CGFloat, eventColor: UIColor) { - let tagSpacing: CGFloat = 4 + private func addTagsLayers(eventTags: [EventTag], x: CGFloat, y: CGFloat, maxWidth: CGFloat, layout: DayViewCellLayout, eventColor: UIColor) { + let tagHeight = layout.tagHeight + let tagSpacing = layout.tagSpacing + let tagCornerRadius = layout.tagCornerRadius + let tagTextSize = layout.tagTextSize let tagPadding: CGFloat = 6 - let tagCornerRadius: CGFloat = tagHeight / 2 - let iconSize: CGFloat = tagHeight // Icons same height as pills + let iconSize: CGFloat = tagHeight var currentX: CGFloat = x - for tag in tags { - let tagLower = tag.lowercased() + // Process tags + for eventTag in eventTags { + let tagName = eventTag.name + let tagColor = eventTag.color + + let tagLower = tagName.lowercased() // Try to load icon for any tag (automatically detects from Images.xcassets/tags/) let iconImage = loadIconImage(named: tagLower) + // Check if tag is emoji-only + let isEmojiOnly = isEmoji(tagName) + // Calculate tag width var tagWidth: CGFloat - let tagFont = UIFont(name: "Montserrat-Medium", size: 10) ?? UIFont.systemFont(ofSize: 10, weight: .medium) + let tagFont = UIFont(name: "Montserrat-Medium", size: tagTextSize) ?? UIFont.systemFont(ofSize: tagTextSize, weight: .medium) if iconImage != nil { - tagWidth = iconSize // Just icon width, no padding + // Icon from asset + tagWidth = iconSize + } else if isEmojiOnly { + // Emoji-only: no background, just emoji text + let emojiSize = tagName.size(withAttributes: [.font: UIFont.systemFont(ofSize: tagTextSize + 4)]) + tagWidth = emojiSize.width + 4 // Small margin around emoji } else { - // Text only tags with padding (fallback when icon not found) - let tagText = tag as NSString + // Text-only with color background + let tagText = tagName as NSString let textWidth = tagText.size(withAttributes: [.font: tagFont]).width tagWidth = textWidth + (tagPadding * 2) } @@ -106,20 +120,37 @@ class EventLayer: CALayer { height: iconSize ) iconLayer.contentsGravity = .resizeAspect - // Use destination out blend mode for transparent icons iconLayer.compositingFilter = "destinationOut" self.addSublayer(iconLayer) + } else if isEmojiOnly { + // Render emoji-only text without background (no foregroundColor set to preserve emoji colors) + let emojiTextLayer = CATextLayer() + + emojiTextLayer.frame = CGRect( + x: currentX, + y: y, + width: tagWidth, + height: tagHeight + ) + emojiTextLayer.string = tagName + emojiTextLayer.font = UIFont.systemFont(ofSize: tagTextSize + 4) + emojiTextLayer.fontSize = tagTextSize + 4 + emojiTextLayer.contentsScale = UIScreen.main.scale + emojiTextLayer.alignmentMode = .center + emojiTextLayer.isWrapped = false + self.addSublayer(emojiTextLayer) } else { - // Create tag background layer (white pill for text fallback) + // Text with tag color background let tagBackgroundLayer = CALayer() + tagBackgroundLayer.frame = CGRect(x: currentX, y: y, width: tagWidth, height: tagHeight) - tagBackgroundLayer.backgroundColor = UIColor.white.cgColor + tagBackgroundLayer.backgroundColor = tagColor.cgColor tagBackgroundLayer.cornerRadius = tagCornerRadius self.addSublayer(tagBackgroundLayer) - // Create tag text layer with Montserrat Medium + // Create tag text layer with event color (creates stamp out effect) let tagTextLayer = CATextLayer() - let tagText = tag as NSString + let tagText = tagName as NSString let textWidth = tagText.size(withAttributes: [.font: tagFont]).width tagTextLayer.frame = CGRect( @@ -128,9 +159,9 @@ class EventLayer: CALayer { width: textWidth, height: tagHeight - 6 ) - tagTextLayer.string = tag + tagTextLayer.string = tagName tagTextLayer.font = tagFont - tagTextLayer.fontSize = 10 + tagTextLayer.fontSize = tagTextSize tagTextLayer.foregroundColor = eventColor.cgColor tagTextLayer.contentsScale = UIScreen.main.scale tagTextLayer.alignmentMode = .center @@ -141,6 +172,27 @@ class EventLayer: CALayer { currentX += tagWidth + tagSpacing } } + + let cleaned = string.trimmingCharacters(in: .whitespaces) + if cleaned.isEmpty { + return false + } + + // Check if all characters are emoji + for scalar in cleaned.unicodeScalars { + // Skip variation selectors and joiners + if scalar.properties.isEmoji || + scalar.properties.isEmojiComponent || + scalar == "\u{200D}" { // Zero-width joiner + continue + } + // If we encounter a non-emoji character, it's not emoji-only + if !scalar.properties.isWhitespace { + return false + } + } + return true + } private func loadIconImage(named: String) -> UIImage? { // Try to load from main app bundle under tags namespace (Images.xcassets/tags/) diff --git a/QVRWeekView/Classes/Common/Extensions.swift b/QVRWeekView/Classes/Common/Extensions.swift index 9b875fe..70a2513 100644 --- a/QVRWeekView/Classes/Common/Extensions.swift +++ b/QVRWeekView/Classes/Common/Extensions.swift @@ -209,4 +209,4 @@ extension Dictionary where Key == DayDate, Value == [EventData] { self[dayDate]!.append(event) } } -} +} \ No newline at end of file