From f752609c9a1dd86ca9b6d1cf7eed5d602a836390 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Tue, 30 Dec 2025 11:23:14 -0300 Subject: [PATCH 1/3] Improves feed highlights carousel performance Optimizes the feed highlights carousel for better performance by reducing shadow complexity and utilizing drawing groups. Introduces a vertical carousel layout for landscape mode, providing an alternative display of highlights alongside the main feed. Enhances accessibility and provides a more refined user experience through adjusted card layouts and auto-scroll improvements. --- .../Components/FeedHighlightCardView.swift | 85 ++++- .../FeedHighlightsCarouselView.swift | 339 +++++++++++++----- .../FeedHighlightsVerticalView.swift | 291 +++++++++++++++ .../Sources/NewsLibrary/Views/NewsView.swift | 210 +++++++++-- 4 files changed, 786 insertions(+), 139 deletions(-) create mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift index d5b7782e..0b3c05f9 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift @@ -5,7 +5,8 @@ import UIComponentsLibrary // MARK: - Feed Highlight Card View -/// Card view for featured/highlighted posts in the carousel +/// Card view for featured/highlighted posts in the carousel. +/// Optimized for performance with reduced shadows and drawingGroup. public struct FeedHighlightCardView: View { // MARK: - Properties @@ -28,10 +29,14 @@ public struct FeedHighlightCardView: View { .frame(width: geometry.size.width) } } - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.3), radius: 12, x: 0, y: 6) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .compositingGroup() + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + .drawingGroup() .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityLabel) + .accessibilityHint("Toque duas vezes para ler a notícia") + .accessibilityAddTraits(.isButton) } // MARK: - Background Image @@ -59,8 +64,8 @@ public struct FeedHighlightCardView: View { LinearGradient( gradient: Gradient(stops: [ .init(color: .clear, location: 0.0), - .init(color: .black.opacity(0.2), location: 0.5), - .init(color: .black.opacity(0.85), location: 1.0) + .init(color: .black.opacity(0.15), location: 0.5), + .init(color: .black.opacity(0.75), location: 1.0) ]), startPoint: .top, endPoint: .bottom @@ -70,29 +75,29 @@ public struct FeedHighlightCardView: View { // MARK: - Content Overlay private var contentOverlay: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { Spacer() Text(post.title) - .font(.title2) - .fontWeight(.bold) + .font(.headline) + .fontWeight(.semibold) .foregroundStyle(.white) .lineLimit(3) .multilineTextAlignment(.leading) dateLabel } - .padding(20) + .padding(16) .frame(maxWidth: .infinity, alignment: .leading) } private var dateLabel: some View { - HStack(spacing: 6) { + HStack(spacing: 4) { Image(systemName: "calendar") Text(post.pubDate.toTimeAgoDisplay(showTime: true)) } - .font(.subheadline) - .foregroundStyle(.white.opacity(0.85)) + .font(.caption) + .foregroundStyle(.white.opacity(0.8)) } // MARK: - Accessibility @@ -102,7 +107,7 @@ public struct FeedHighlightCardView: View { if post.favorite { label += ", favoritado" } - label += ", publicado em \(post.pubDate.toTimeAgoDisplay(showTime: true))" + label += ", publicado em \(post.pubDate.toTimeAgoDisplay(showTime: true)))" return label } @@ -122,15 +127,61 @@ private struct FeedHighlightCardPreview: View { Color.black.opacity(0.9).ignoresSafeArea() if let post = PreviewData.sampleHighlights.first { - FeedHighlightCardView(post: post) - .frame(width: 340, height: 420) - .padding() + VStack(spacing: 20) { + Text("Tamanho reduzido") + .foregroundStyle(.white) + .font(.caption) + + FeedHighlightCardView(post: post) + .frame(width: 320, height: 280) + } + .padding() } } } } -#Preview { +#Preview("Card") { FeedHighlightCardPreview() } + +#Preview("Card Sizes") { + ScrollView { + VStack(spacing: 24) { + if let post = PreviewData.sampleHighlights.first { + Group { + VStack(spacing: 8) { + Text("iPhone Portrait (280pt)") + .font(.caption) + FeedHighlightCardView(post: post) + .frame(width: 340, height: 280) + } + + VStack(spacing: 8) { + Text("iPhone Landscape (180pt)") + .font(.caption) + FeedHighlightCardView(post: post) + .frame(width: 400, height: 180) + } + + VStack(spacing: 8) { + Text("iPad Portrait (320pt)") + .font(.caption) + FeedHighlightCardView(post: post) + .frame(width: 500, height: 320) + } + + VStack(spacing: 8) { + Text("iPad Landscape (240pt)") + .font(.caption) + FeedHighlightCardView(post: post) + .frame(width: 380, height: 240) + } + } + } + } + .padding() + } + .background(Color.gray.opacity(0.2)) +} #endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift index 70fb9df4..8cb94556 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift @@ -5,76 +5,107 @@ import UIKit // MARK: - Feed Highlights Carousel View -/// Auto-scrolling carousel for featured posts with large centered card and peek on edges +/// Carousel for featured posts with centered card and peek on edges public struct FeedHighlightsCarouselView: View { // MARK: - Properties let highlights: [FeedDB] - let sectionId: String - let heroNamespace: Namespace.ID? + let isAutoScrollEnabled: Bool let onTap: (FeedDB) -> Void @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass + @Environment(\.isSidebarVisible) private var isSidebarVisible @State private var currentIndex: Int = 0 + @State private var dragOffset: CGFloat = 0 @State private var timer: Timer? @State private var lastInteractionDate = Date() @State private var isAutoScrollPaused = false - @State private var dragOffset: CGFloat = 0 - - // Auto-scroll settings - private let autoScrollInterval: TimeInterval = 10.0 - private let pauseDuration: TimeInterval = 30.0 + @State private var containerWidth: CGFloat = 0 + + // MARK: - Layout Constants + + private enum Layout { + // Card heights + static let phonePortraitHeight: CGFloat = 280 + static let phoneLandscapeHeight: CGFloat = 180 + static let padPortraitHeight: CGFloat = 320 + static let padLandscapeHeight: CGFloat = 240 + + // Spacing and peek + static let spacing: CGFloat = 12 + static let phonePeek: CGFloat = 40 + static let padPeek: CGFloat = 60 + static let padLeadingPadding: CGFloat = 16 + + // Rubber band effect + static let rubberBandFactor: CGFloat = 0.3 + static let maxRubberBandDrag: CGFloat = 40 + + // Auto-scroll timing + static let autoScrollInterval: TimeInterval = 8.0 + static let pauseDuration: TimeInterval = 30.0 + } // MARK: - Computed Properties - /// Check if device is in landscape mode + private var isIPad: Bool { + horizontalSizeClass == .regular && verticalSizeClass == .regular + } + private var isLandscape: Bool { verticalSizeClass == .compact } - /// Check if running on iPad - private var isIPad: Bool { - horizontalSizeClass == .regular && verticalSizeClass == .regular + /// Detect iPad landscape by width (landscape is wider than 900pt typically) + private func isIPadLandscapeMode(screenWidth: CGFloat) -> Bool { + isIPad && screenWidth > 900 } - /// Card height based on orientation private var cardHeight: CGFloat { + let height: CGFloat if isIPad { - return 500 + // Use containerWidth for landscape detection + let isLandscapeMode = containerWidth > 900 + height = isLandscapeMode ? Layout.padLandscapeHeight : Layout.padPortraitHeight + } else { + height = isLandscape ? Layout.phoneLandscapeHeight : Layout.phonePortraitHeight } - return isLandscape ? 240 : 420 + return max(height, 100) } - /// Spacing between cards - private var spacing: CGFloat { - if isIPad { - return 20 - } - return isLandscape ? 24 : 0 + private var peekWidth: CGFloat { + isIPad ? Layout.padPeek : Layout.phonePeek } - /// Peek width (visible portion of adjacent cards) - private var peekWidth: CGFloat { - if isIPad { - return 80 + /// Number of visible cards based on device, orientation and sidebar visibility + /// - Portrait with sidebar: 1 card + /// - Portrait without sidebar: 2 cards + /// - Landscape (with or without sidebar): 2 cards + private func visibleCardCount(for screenWidth: CGFloat) -> Int { + guard isIPad else { return 1 } + + // Detect landscape by screen width (> 900pt is typically landscape on iPad) + let isLandscapeMode = screenWidth > 900 + + if isLandscapeMode { + return 2 + } else { + return isSidebarVisible ? 1 : 2 } - return isLandscape ? 10 : 25 } // MARK: - Initialization public init( highlights: [FeedDB], - sectionId: String = "highlights", - heroNamespace: Namespace.ID? = nil, + isAutoScrollEnabled: Bool = false, onTap: @escaping (FeedDB) -> Void ) { self.highlights = highlights - self.sectionId = sectionId - self.heroNamespace = heroNamespace + self.isAutoScrollEnabled = isAutoScrollEnabled self.onTap = onTap } @@ -82,39 +113,19 @@ public struct FeedHighlightsCarouselView: View { public var body: some View { GeometryReader { geometry in - let screenWidth = geometry.size.width - let cardWidth = screenWidth - (peekWidth * 2) - spacing + let metrics = calculateCardMetrics(screenWidth: geometry.size.width) ZStack { ForEach(Array(highlights.enumerated()), id: \.element.postId) { index, post in - let offset = calculateOffset( - for: index, - currentIndex: currentIndex, - cardWidth: cardWidth, - spacing: spacing, - dragOffset: dragOffset + cardView( + post: post, + index: index, + metrics: metrics, + screenWidth: geometry.size.width ) - - let scale = calculateScale(for: index, currentIndex: currentIndex) - let opacity = calculateOpacity(for: index, currentIndex: currentIndex) - - FeedHighlightCardView(post: post) - .frame(width: cardWidth, height: cardHeight) - .scaleEffect(scale) - .opacity(opacity) - .offset(x: offset) - .zIndex(index == currentIndex ? 1 : 0) - .allowsHitTesting(index == currentIndex) - .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .onTapGesture { - guard index == currentIndex else { return } - onTap(post) - } - .animation(.easeInOut(duration: 0.3), value: currentIndex) - .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.8), value: dragOffset) } } - .frame(width: screenWidth, height: cardHeight + 20) + .frame(width: geometry.size.width, height: cardHeight) .contentShape(Rectangle()) .overlay { HorizontalPanCaptureView( @@ -122,70 +133,172 @@ public struct FeedHighlightsCarouselView: View { guard highlights.indices.contains(currentIndex) else { return } onTap(highlights[currentIndex]) }, - onChanged: { value in - pauseAutoScroll() - dragOffset = value + onChanged: { delta in + handleDragChanged(delta: delta) }, - onEnded: { translationX, velocityX in - handlePanEnded(translationX: translationX, - velocityX: velocityX) + onEnded: { delta, velocity in + handleDragEnded(delta: delta, velocity: velocity) } ) } + .onAppear { + containerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { _, newWidth in + containerWidth = newWidth + } } - .frame(height: cardHeight + 20) - .onAppear { startAutoScroll() } + .frame(height: cardHeight) + .onAppear { startAutoScrollIfEnabled() } .onDisappear { stopAutoScroll() } } - // MARK: - Pan Handling (UIKit) + // MARK: - Card View + + @ViewBuilder + private func cardView( + post: FeedDB, + index: Int, + metrics: CardMetrics, + screenWidth: CGFloat + ) -> some View { + let offset = calculateOffset(for: index, metrics: metrics, screenWidth: screenWidth) + let scale = calculateScale(for: index) + let opacity = calculateOpacity(for: index) + + FeedHighlightCardView(post: post) + .frame(width: metrics.cardWidth, height: cardHeight) + .scaleEffect(scale) + .opacity(opacity) + .offset(x: offset) + .zIndex(index == currentIndex ? 1 : 0) + .allowsHitTesting(index == currentIndex) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .onTapGesture { + guard index == currentIndex else { return } + onTap(post) + } + .animation(.easeOut(duration: 0.25), value: currentIndex) + .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.85), value: dragOffset) + } + + // MARK: - Card Metrics + + private struct CardMetrics { + let cardWidth: CGFloat + let totalCardSpace: CGFloat + let initialOffset: CGFloat + } + + private func calculateCardMetrics(screenWidth: CGFloat) -> CardMetrics { + let cardWidth: CGFloat + let initialOffset: CGFloat + let cardCount = visibleCardCount(for: screenWidth) + + if isIPad { + // iPad: align to leading edge with peek on right + let availableWidth = screenWidth - Layout.padLeadingPadding - Layout.padPeek + let spacingTotal = Layout.spacing * CGFloat(max(cardCount - 1, 0)) + let calculated = (availableWidth - spacingTotal) / CGFloat(max(cardCount, 1)) + cardWidth = max(calculated, 100) + initialOffset = 0 + } else { + // iPhone: single card centered with peek on both sides + let availableWidth = screenWidth - (peekWidth * 2) + cardWidth = max(availableWidth, 100) + initialOffset = 0 + } + + let totalCardSpace = cardWidth + Layout.spacing - private func handlePanEnded(translationX: CGFloat, velocityX: CGFloat) { + return CardMetrics(cardWidth: cardWidth, totalCardSpace: totalCardSpace, initialOffset: initialOffset) + } + + // MARK: - Drag Handling + + private func handleDragChanged(delta: CGFloat) { + pauseAutoScroll() + + let isAtStart = currentIndex == 0 + let isAtEnd = currentIndex >= highlights.count - 1 + + // Apply rubber band effect at boundaries + if (isAtStart && delta > 0) || (isAtEnd && delta < 0) { + let rubberBandDelta = rubberBand(delta: delta) + dragOffset = rubberBandDelta + } else { + dragOffset = delta + } + } + + private func handleDragEnded(delta: CGFloat, velocity: CGFloat) { let threshold: CGFloat = 50 + let velocityThreshold: CGFloat = 500 - withAnimation(.easeInOut(duration: 0.3)) { - if translationX < -threshold || velocityX < -600 { - if currentIndex < highlights.count - 1 { - currentIndex += 1 - } - } else if translationX > threshold || velocityX > 600 { - if currentIndex > 0 { - currentIndex -= 1 - } + withAnimation(.easeOut(duration: 0.25)) { + let shouldAdvance = delta < -threshold || velocity < -velocityThreshold + let shouldRetreat = delta > threshold || velocity > velocityThreshold + + if shouldAdvance && currentIndex < highlights.count - 1 { + currentIndex += 1 + } else if shouldRetreat && currentIndex > 0 { + currentIndex -= 1 } + dragOffset = 0 } } + /// Rubber band effect for edge resistance + private func rubberBand(delta: CGFloat) -> CGFloat { + let sign: CGFloat = delta > 0 ? 1 : -1 + let magnitude = abs(delta) + let dampened = magnitude * Layout.rubberBandFactor + let capped = min(dampened, Layout.maxRubberBandDrag) + return sign * capped + } + // MARK: - Layout Calculations - private func calculateOffset( - for index: Int, - currentIndex: Int, - cardWidth: CGFloat, - spacing: CGFloat, - dragOffset: CGFloat - ) -> CGFloat { - let diff = index - currentIndex - let baseOffset = CGFloat(diff) * (cardWidth + spacing) - return baseOffset + dragOffset + private func calculateOffset(for index: Int, metrics: CardMetrics, screenWidth: CGFloat) -> CGFloat { + if isIPad { + // iPad: align cards to leading edge with peek on right + let leadingEdge = -screenWidth / 2 + Layout.padLeadingPadding + metrics.cardWidth / 2 + let cardPosition = leadingEdge + CGFloat(index) * metrics.totalCardSpace + let scrollOffset = CGFloat(currentIndex) * metrics.totalCardSpace + return cardPosition - scrollOffset + dragOffset + } else { + // iPhone: centered card behavior + let diff = index - currentIndex + let baseOffset = CGFloat(diff) * metrics.totalCardSpace + return baseOffset + dragOffset + } } - private func calculateScale(for index: Int, currentIndex: Int) -> CGFloat { - index == currentIndex ? 1.0 : 0.92 + private func calculateScale(for index: Int) -> CGFloat { + index == currentIndex ? 1.0 : 0.95 } - private func calculateOpacity(for index: Int, currentIndex: Int) -> Double { + private func calculateOpacity(for index: Int) -> Double { let diff = abs(index - currentIndex) switch diff { - case 0: return 1.0 - case 1: return 0.7 - case 2: return 0.4 - default: return 0 + case 0: + return 1.0 + case 1: + return 0.85 + case 2: + return 0.5 + default: + return 0 } } - // MARK: - Auto Scroll Control + // MARK: - Auto Scroll + + private func startAutoScrollIfEnabled() { + guard isAutoScrollEnabled else { return } + startAutoScroll() + } private func startAutoScroll() { stopAutoScroll() @@ -203,6 +316,7 @@ public struct FeedHighlightsCarouselView: View { } private func pauseAutoScroll() { + guard isAutoScrollEnabled else { return } lastInteractionDate = Date() isAutoScrollPaused = true } @@ -211,14 +325,14 @@ public struct FeedHighlightsCarouselView: View { let timeSinceInteraction = Date().timeIntervalSince(lastInteractionDate) if isAutoScrollPaused { - if timeSinceInteraction >= pauseDuration { + if timeSinceInteraction >= Layout.pauseDuration { isAutoScrollPaused = false lastInteractionDate = Date() } return } - if timeSinceInteraction >= autoScrollInterval { + if timeSinceInteraction >= Layout.autoScrollInterval { advanceToNextSlide() lastInteractionDate = Date() } @@ -247,6 +361,39 @@ public struct FeedHighlightsCarouselView: View { VStack { FeedHighlightsCarouselView( highlights: PreviewData.sampleHighlights, + isAutoScrollEnabled: false, + onTap: { _ in } + ) + + Spacer() + } + } +} + +#Preview("iPhone Landscape", traits: .landscapeLeft) { + ZStack { + Color.gray.opacity(0.2).ignoresSafeArea() + + VStack { + FeedHighlightsCarouselView( + highlights: PreviewData.sampleHighlights, + isAutoScrollEnabled: false, + onTap: { _ in } + ) + + Spacer() + } + } +} + +#Preview("iPad", traits: .landscapeLeft) { + ZStack { + Color.gray.opacity(0.2).ignoresSafeArea() + + VStack { + FeedHighlightsCarouselView( + highlights: PreviewData.sampleHighlights, + isAutoScrollEnabled: false, onTap: { _ in } ) diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift new file mode 100644 index 00000000..31a849bc --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift @@ -0,0 +1,291 @@ +import FeedLibrary +import SwiftUI +import UIKit + +// MARK: - Feed Highlights Vertical View + +/// Vertical carousel for highlight cards in landscape mode. +/// Shows centered card with peek of adjacent cards, same behavior as horizontal carousel. +struct FeedHighlightsVerticalView: View { + + // MARK: - Properties + + let highlights: [FeedDB] + let isAutoScrollEnabled: Bool + let onTap: (FeedDB) -> Void + + @State private var currentIndex: Int = 0 + @State private var dragOffset: CGFloat = 0 + @State private var timer: Timer? + @State private var lastInteractionDate = Date() + @State private var isAutoScrollPaused = false + + // MARK: - Layout Constants + + private enum Layout { + static let horizontalPadding: CGFloat = 16 + static let spacing: CGFloat = 12 + static let peekHeight: CGFloat = 16 + + // Rubber band effect + static let rubberBandFactor: CGFloat = 0.3 + static let maxRubberBandDrag: CGFloat = 40 + + // Auto-scroll timing + static let autoScrollInterval: TimeInterval = 8.0 + static let pauseDuration: TimeInterval = 30.0 + } + + // MARK: - Initialization + + init( + highlights: [FeedDB], + isAutoScrollEnabled: Bool = false, + onTap: @escaping (FeedDB) -> Void + ) { + self.highlights = highlights + self.isAutoScrollEnabled = isAutoScrollEnabled + self.onTap = onTap + } + + // MARK: - Body + + var body: some View { + GeometryReader { geometry in + let metrics = calculateCardMetrics(availableHeight: geometry.size.height) + let cardWidth = geometry.size.width - (Layout.horizontalPadding * 2) + + ZStack { + ForEach(Array(highlights.enumerated()), id: \.element.postId) { index, post in + cardView( + post: post, + index: index, + cardWidth: cardWidth, + metrics: metrics + ) + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + .contentShape(Rectangle()) + .gesture(dragGesture) + } + .onAppear { startAutoScrollIfEnabled() } + .onDisappear { stopAutoScroll() } + } + + // MARK: - Card View + + @ViewBuilder + private func cardView( + post: FeedDB, + index: Int, + cardWidth: CGFloat, + metrics: CardMetrics + ) -> some View { + let offset = calculateOffset(for: index, metrics: metrics) + let scale = calculateScale(for: index) + let opacity = calculateOpacity(for: index) + + FeedHighlightCardView(post: post) + .frame(width: cardWidth, height: metrics.cardHeight) + .scaleEffect(scale) + .opacity(opacity) + .offset(y: offset) + .zIndex(index == currentIndex ? 1 : 0) + .allowsHitTesting(index == currentIndex) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .onTapGesture { + guard index == currentIndex else { return } + onTap(post) + } + .animation(.easeOut(duration: 0.25), value: currentIndex) + .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.85), value: dragOffset) + } + + // MARK: - Drag Gesture + + private var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + handleDragChanged(delta: value.translation.height) + } + .onEnded { value in + handleDragEnded( + delta: value.translation.height, + velocity: value.predictedEndTranslation.height - value.translation.height + ) + } + } + + // MARK: - Card Metrics + + private struct CardMetrics { + let cardHeight: CGFloat + let totalCardSpace: CGFloat + } + + private func calculateCardMetrics(availableHeight: CGFloat) -> CardMetrics { + let cardHeight = availableHeight - (Layout.peekHeight * 2) - Layout.spacing + let totalCardSpace = cardHeight + Layout.spacing + return CardMetrics(cardHeight: cardHeight, totalCardSpace: totalCardSpace) + } + + // MARK: - Drag Handling + + private func handleDragChanged(delta: CGFloat) { + pauseAutoScroll() + + let isAtStart = currentIndex == 0 + let isAtEnd = currentIndex >= highlights.count - 1 + + // Apply rubber band effect at boundaries + if (isAtStart && delta > 0) || (isAtEnd && delta < 0) { + let rubberBandDelta = rubberBand(delta: delta) + dragOffset = rubberBandDelta + } else { + dragOffset = delta + } + } + + private func handleDragEnded(delta: CGFloat, velocity: CGFloat) { + let threshold: CGFloat = 50 + let velocityThreshold: CGFloat = 500 + + withAnimation(.easeOut(duration: 0.25)) { + let shouldAdvance = delta < -threshold || velocity < -velocityThreshold + let shouldRetreat = delta > threshold || velocity > velocityThreshold + + if shouldAdvance && currentIndex < highlights.count - 1 { + currentIndex += 1 + } else if shouldRetreat && currentIndex > 0 { + currentIndex -= 1 + } + + dragOffset = 0 + } + } + + /// Rubber band effect for edge resistance + private func rubberBand(delta: CGFloat) -> CGFloat { + let sign: CGFloat = delta > 0 ? 1 : -1 + let magnitude = abs(delta) + let dampened = magnitude * Layout.rubberBandFactor + let capped = min(dampened, Layout.maxRubberBandDrag) + return sign * capped + } + + // MARK: - Layout Calculations + + private func calculateOffset(for index: Int, metrics: CardMetrics) -> CGFloat { + let diff = index - currentIndex + let baseOffset = CGFloat(diff) * metrics.totalCardSpace + return baseOffset + dragOffset + } + + private func calculateScale(for index: Int) -> CGFloat { + index == currentIndex ? 1.0 : 0.95 + } + + private func calculateOpacity(for index: Int) -> Double { + let diff = abs(index - currentIndex) + switch diff { + case 0: + return 1.0 + case 1: + return 0.85 + case 2: + return 0.5 + default: + return 0 + } + } + + // MARK: - Auto Scroll + + private func startAutoScrollIfEnabled() { + guard isAutoScrollEnabled else { return } + startAutoScroll() + } + + private func startAutoScroll() { + stopAutoScroll() + + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + Task { @MainActor in + checkAndAdvance() + } + } + } + + private func stopAutoScroll() { + timer?.invalidate() + timer = nil + } + + private func pauseAutoScroll() { + guard isAutoScrollEnabled else { return } + lastInteractionDate = Date() + isAutoScrollPaused = true + } + + private func checkAndAdvance() { + let timeSinceInteraction = Date().timeIntervalSince(lastInteractionDate) + + if isAutoScrollPaused { + if timeSinceInteraction >= Layout.pauseDuration { + isAutoScrollPaused = false + lastInteractionDate = Date() + } + return + } + + if timeSinceInteraction >= Layout.autoScrollInterval { + advanceToNextSlide() + lastInteractionDate = Date() + } + } + + private func advanceToNextSlide() { + guard !highlights.isEmpty else { return } + + withAnimation(.easeInOut(duration: 0.5)) { + if currentIndex < highlights.count - 1 { + currentIndex += 1 + } else { + currentIndex = 0 + } + } + } +} + +// MARK: - Preview + +#if DEBUG +#Preview("Vertical Highlights") { + FeedHighlightsVerticalView( + highlights: PreviewData.sampleHighlights, + isAutoScrollEnabled: false, + onTap: { _ in } + ) + .background(Color.gray.opacity(0.1)) +} + +#Preview("Vertical Highlights - Landscape", traits: .landscapeLeft) { + HStack(spacing: 0) { + FeedHighlightsVerticalView( + highlights: PreviewData.sampleHighlights, + isAutoScrollEnabled: false, + onTap: { _ in } + ) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.1)) + + Divider() + + Rectangle() + .fill(Color.blue.opacity(0.1)) + .frame(maxWidth: .infinity) + .overlay(Text("Feed")) + } +} +#endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift index 60d0622f..889d5fe9 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift @@ -7,12 +7,28 @@ import SwiftData import SwiftUI import UIComponentsLibrary +// MARK: - News View + public struct NewsView: View { + + // MARK: - Feature Flags + + /// Enable or disable auto-scroll for highlights carousel. + /// Set to `true` to enable automatic advancement of cards. + private let isAutoScrollEnabled = false + + // MARK: - Environment + @Environment(\.theme) private var theme: ThemeColor @Environment(\.modelContext) private var modelContext + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + @EnvironmentObject private var sessionState: SessionState @EnvironmentObject private var analytics: AnalyticsManager + // MARK: - Properties + var viewModel: NewsViewModel @Binding private var favorite: Bool @@ -24,6 +40,23 @@ public struct NewsView: View { @Query(sort: \FeedDB.pubDate, order: .reverse) private var allNews: [FeedDB] + // MARK: - Computed Properties + + /// Check if device is iPad + private var isIPad: Bool { + horizontalSizeClass == .regular && verticalSizeClass == .regular + } + + /// Check if device is in landscape mode (iPhone only) + private var isLandscape: Bool { + verticalSizeClass == .compact + } + + /// Number of highlights to show (30 for iPad, 10 for iPhone) + private var highlightsLimit: Int { + isIPad ? 30 : 10 + } + /// Filtered highlights from allNews private var highlights: [FeedDB] { allNews.filter { $0.categories.contains("Destaques") } @@ -45,6 +78,8 @@ public struct NewsView: View { !favorite && category == .all && !highlights.isEmpty } + // MARK: - Initialization + public init( storage: Database, favorite: Binding, @@ -57,6 +92,8 @@ public struct NewsView: View { _scrollPosition = scrollPosition } + // MARK: - Body + public var body: some View { content .refreshable { @@ -73,9 +110,23 @@ public struct NewsView: View { } } +// MARK: - Content + extension NewsView { + @ViewBuilder var content: some View { + if isLandscape && shouldShowHighlights { + landscapeContent + } else { + portraitContent + } + } + + // MARK: - Portrait Layout + + @ViewBuilder + private var portraitContent: some View { let retryAction: () -> Void = { Task { try? await viewModel.getNews() @@ -93,43 +144,105 @@ extension NewsView { header: { if shouldShowHighlights { FeedHighlightsCarouselView( - highlights: Array(highlights.prefix(10)), - onTap: { _ in } + highlights: Array(highlights.prefix(highlightsLimit)), + isAutoScrollEnabled: isAutoScrollEnabled, + onTap: { post in + handleHighlightTap(post) + } ) } }, content: { - ForEach( - 0.. Void = { + Task { + try? await viewModel.getNews() + } + } + + HStack(spacing: 0) { + // Left column: Highlights (vertical scroll) + FeedHighlightsVerticalView( + highlights: Array(highlights.prefix(highlightsLimit)), + isAutoScrollEnabled: isAutoScrollEnabled, + onTap: { post in + handleHighlightTap(post) + } + ) + .frame(maxWidth: .infinity) + + Divider() + + // Right column: Feed + CollectionViewWithHeader( + title: "Notícias", + status: viewModel.status, + usesDensity: true, + scrollPosition: $scrollPosition, + favorite: favorite, + isSearching: !search.isEmpty, + quantity: search.isEmpty ? news.count : 0, + content: { + newsCards + }, + retryAction: favorite ? nil : retryAction + ) + .frame(maxWidth: .infinity) + } + } + + // MARK: - News Cards + + @ViewBuilder + private var newsCards: some View { + ForEach(0.. Date: Tue, 30 Dec 2025 12:13:41 -0300 Subject: [PATCH 2/3] Removes placeholder action Removes the placeholder implementation for handling highlight taps. This streamlines the codebase and prepares for the actual implementation. --- .../NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift index 889d5fe9..417b75c1 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift @@ -230,9 +230,7 @@ extension NewsView { // MARK: - Actions - private func handleHighlightTap(_ post: FeedDB) { - // TODO: Implement highlight tap navigation - } + private func handleHighlightTap(_ post: FeedDB) {} } // MARK: - Preview From ea98415270c9f82a2a8662c81a0a5d35f42aa630 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Tue, 30 Dec 2025 12:25:37 -0300 Subject: [PATCH 3/3] Refactors highlight views to use shared index Moves the highlight index state out of the individual carousel and vertical highlight views, and passes it in as a binding. This change allows the parent view to control and synchronize the index across both views, ensuring consistent highlight selection between the carousel and vertical list. --- .../Views/Components/FeedHighlightsCarouselView.swift | 8 +++++++- .../Views/Components/FeedHighlightsVerticalView.swift | 9 ++++++++- .../NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift | 9 ++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift index 8cb94556..6d1d8730 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift @@ -14,11 +14,12 @@ public struct FeedHighlightsCarouselView: View { let isAutoScrollEnabled: Bool let onTap: (FeedDB) -> Void + @Binding var currentIndex: Int + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.isSidebarVisible) private var isSidebarVisible - @State private var currentIndex: Int = 0 @State private var dragOffset: CGFloat = 0 @State private var timer: Timer? @State private var lastInteractionDate = Date() @@ -101,10 +102,12 @@ public struct FeedHighlightsCarouselView: View { public init( highlights: [FeedDB], + currentIndex: Binding, isAutoScrollEnabled: Bool = false, onTap: @escaping (FeedDB) -> Void ) { self.highlights = highlights + self._currentIndex = currentIndex self.isAutoScrollEnabled = isAutoScrollEnabled self.onTap = onTap } @@ -361,6 +364,7 @@ public struct FeedHighlightsCarouselView: View { VStack { FeedHighlightsCarouselView( highlights: PreviewData.sampleHighlights, + currentIndex: .constant(0), isAutoScrollEnabled: false, onTap: { _ in } ) @@ -377,6 +381,7 @@ public struct FeedHighlightsCarouselView: View { VStack { FeedHighlightsCarouselView( highlights: PreviewData.sampleHighlights, + currentIndex: .constant(0), isAutoScrollEnabled: false, onTap: { _ in } ) @@ -393,6 +398,7 @@ public struct FeedHighlightsCarouselView: View { VStack { FeedHighlightsCarouselView( highlights: PreviewData.sampleHighlights, + currentIndex: .constant(0), isAutoScrollEnabled: false, onTap: { _ in } ) diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift index 31a849bc..6bb7d312 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsVerticalView.swift @@ -14,7 +14,8 @@ struct FeedHighlightsVerticalView: View { let isAutoScrollEnabled: Bool let onTap: (FeedDB) -> Void - @State private var currentIndex: Int = 0 + @Binding var currentIndex: Int + @State private var dragOffset: CGFloat = 0 @State private var timer: Timer? @State private var lastInteractionDate = Date() @@ -38,12 +39,16 @@ struct FeedHighlightsVerticalView: View { // MARK: - Initialization + // MARK: - Initialization + init( highlights: [FeedDB], + currentIndex: Binding, isAutoScrollEnabled: Bool = false, onTap: @escaping (FeedDB) -> Void ) { self.highlights = highlights + self._currentIndex = currentIndex self.isAutoScrollEnabled = isAutoScrollEnabled self.onTap = onTap } @@ -264,6 +269,7 @@ struct FeedHighlightsVerticalView: View { #Preview("Vertical Highlights") { FeedHighlightsVerticalView( highlights: PreviewData.sampleHighlights, + currentIndex: .constant(0), isAutoScrollEnabled: false, onTap: { _ in } ) @@ -274,6 +280,7 @@ struct FeedHighlightsVerticalView: View { HStack(spacing: 0) { FeedHighlightsVerticalView( highlights: PreviewData.sampleHighlights, + currentIndex: .constant(0), isAutoScrollEnabled: false, onTap: { _ in } ) diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift index 417b75c1..ce1a6318 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift @@ -36,6 +36,7 @@ public struct NewsView: View { @Binding var scrollPosition: ScrollPosition @State private var search: String = "" + @State private var highlightIndex: Int = 0 @Query(sort: \FeedDB.pubDate, order: .reverse) private var allNews: [FeedDB] @@ -145,6 +146,7 @@ extension NewsView { if shouldShowHighlights { FeedHighlightsCarouselView( highlights: Array(highlights.prefix(highlightsLimit)), + currentIndex: $highlightIndex, isAutoScrollEnabled: isAutoScrollEnabled, onTap: { post in handleHighlightTap(post) @@ -173,6 +175,7 @@ extension NewsView { // Left column: Highlights (vertical scroll) FeedHighlightsVerticalView( highlights: Array(highlights.prefix(highlightsLimit)), + currentIndex: $highlightIndex, isAutoScrollEnabled: isAutoScrollEnabled, onTap: { post in handleHighlightTap(post) @@ -230,7 +233,7 @@ extension NewsView { // MARK: - Actions - private func handleHighlightTap(_ post: FeedDB) {} + private func handleHighlightTap(_ post: FeedDB) { } } // MARK: - Preview @@ -268,6 +271,8 @@ private struct NewsViewPreviewContent: View { @Binding var category: NewsCategory @Binding var scrollPosition: ScrollPosition + @State private var highlightIndex: Int = 0 + private var isLandscape: Bool { verticalSizeClass == .compact } @@ -277,6 +282,7 @@ private struct NewsViewPreviewContent: View { HStack(spacing: 0) { FeedHighlightsVerticalView( highlights: PreviewData.sampleHighlights, + currentIndex: $highlightIndex, onTap: { _ in } ) .frame(maxWidth: .infinity) @@ -300,6 +306,7 @@ private struct NewsViewPreviewContent: View { VStack(spacing: 0) { FeedHighlightsCarouselView( highlights: PreviewData.sampleHighlights, + currentIndex: $highlightIndex, onTap: { _ in } ) .padding(.bottom, 16)