expo-tvos-search 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ import Foundation
2
+
3
+ /// Parses hex color strings into RGBA components.
4
+ /// Extracted from the Color extension to enable unit testing without tvOS target.
5
+ struct HexColorParser {
6
+ struct RGBA: Equatable {
7
+ let red: Double
8
+ let green: Double
9
+ let blue: Double
10
+ let alpha: Double
11
+ }
12
+
13
+ /// Maximum input length for hex color strings to prevent DoS from very long strings.
14
+ static let maxInputLength = 20
15
+
16
+ /// Parses a hex color string into RGBA components (0.0–1.0 range).
17
+ /// Supports 3-char (RGB), 6-char (RRGGBB), and 8-char (AARRGGBB) formats.
18
+ /// Leading "#" or other non-alphanumeric characters are stripped.
19
+ /// Returns nil if the string cannot be parsed.
20
+ static func parse(_ hex: String) -> RGBA? {
21
+ guard hex.count <= maxInputLength else {
22
+ return nil
23
+ }
24
+
25
+ let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
26
+
27
+ guard hex.count == 3 || hex.count == 6 || hex.count == 8 else {
28
+ return nil
29
+ }
30
+
31
+ var int: UInt64 = 0
32
+ guard Scanner(string: hex).scanHexInt64(&int) else {
33
+ return nil
34
+ }
35
+
36
+ let a, r, g, b: UInt64
37
+ switch hex.count {
38
+ case 3: // RGB (12-bit)
39
+ (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
40
+ case 6: // RGB (24-bit)
41
+ (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
42
+ case 8: // ARGB (32-bit)
43
+ (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
44
+ default:
45
+ return nil
46
+ }
47
+
48
+ return RGBA(
49
+ red: Double(r) / 255,
50
+ green: Double(g) / 255,
51
+ blue: Double(b) / 255,
52
+ alpha: Double(a) / 255
53
+ )
54
+ }
55
+ }
@@ -15,7 +15,6 @@ struct MarqueeText: View {
15
15
  @State private var textWidth: CGFloat = 0
16
16
  @State private var containerWidth: CGFloat = 0
17
17
  @State private var offset: CGFloat = 0
18
- @State private var animationTask: Task<Void, Never>?
19
18
 
20
19
  private let calculator = MarqueeAnimationCalculator()
21
20
 
@@ -39,6 +38,10 @@ struct MarqueeText: View {
39
38
  calculator.shouldScroll(textWidth: textWidth, containerWidth: containerWidth)
40
39
  }
41
40
 
41
+ private var shouldAnimate: Bool {
42
+ animate && needsScroll
43
+ }
44
+
42
45
  var body: some View {
43
46
  GeometryReader { geometry in
44
47
  ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
@@ -57,11 +60,11 @@ struct MarqueeText: View {
57
60
  // Visible text content
58
61
  Group {
59
62
  if needsScroll {
60
- // Duplicated text for seamless scroll loop
61
- Text(text + " " + text)
62
- .font(font)
63
- .fixedSize()
64
- .offset(x: offset)
63
+ HStack(spacing: calculator.spacing) {
64
+ Text(text).font(font).fixedSize()
65
+ Text(text).font(font).fixedSize()
66
+ }
67
+ .offset(x: offset)
65
68
  } else {
66
69
  Text(text)
67
70
  .font(font)
@@ -81,24 +84,29 @@ struct MarqueeText: View {
81
84
  .onAppear {
82
85
  containerWidth = geometry.size.width
83
86
  }
84
- .onChange(of: animate) { shouldAnimate in
85
- if shouldAnimate && needsScroll {
86
- startScrolling()
87
- } else {
88
- stopScrolling()
89
- }
90
- }
91
- .onChange(of: needsScroll) { scrollNeeded in
92
- if animate && scrollNeeded {
93
- startScrolling()
87
+ .task(id: shouldAnimate) {
88
+ if shouldAnimate {
89
+ do {
90
+ try await Task.sleep(nanoseconds: UInt64(startDelay * 1_000_000_000))
91
+ } catch {
92
+ return
93
+ }
94
+ guard !Task.isCancelled else { return }
95
+ let distance = calculator.scrollDistance(textWidth: textWidth)
96
+ let duration = calculator.animationDuration(for: distance)
97
+ withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
98
+ offset = -distance
99
+ }
94
100
  } else {
95
- stopScrolling()
101
+ if offset != 0 {
102
+ withAnimation(.easeOut(duration: 0.2)) {
103
+ offset = 0
104
+ }
105
+ }
96
106
  }
97
107
  }
98
108
  .onDisappear {
99
- // Cancel animation task when view disappears to prevent memory leaks
100
- animationTask?.cancel()
101
- animationTask = nil
109
+ offset = 0
102
110
  }
103
111
  }
104
112
  }
@@ -123,37 +131,6 @@ struct MarqueeText: View {
123
131
  }
124
132
  }
125
133
 
126
- private func startScrolling() {
127
- animationTask?.cancel()
128
- offset = 0
129
-
130
- let distance = calculator.scrollDistance(textWidth: textWidth)
131
- let duration = calculator.animationDuration(for: distance)
132
-
133
- animationTask = Task {
134
- do {
135
- try await Task.sleep(nanoseconds: UInt64(startDelay * 1_000_000_000))
136
- } catch {
137
- return // Task was cancelled
138
- }
139
-
140
- guard !Task.isCancelled else { return }
141
-
142
- await MainActor.run {
143
- withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
144
- offset = -distance
145
- }
146
- }
147
- }
148
- }
149
-
150
- private func stopScrolling() {
151
- animationTask?.cancel()
152
- animationTask = nil
153
- withAnimation(.easeOut(duration: 0.2)) {
154
- offset = 0
155
- }
156
- }
157
134
  }
158
135
 
159
136
  /// PreferenceKey for measuring text width reactively
@@ -0,0 +1,172 @@
1
+ #if os(tvOS)
2
+
3
+ import SwiftUI
4
+
5
+ /// Custom shape for cards with selectively rounded corners
6
+ /// Provides backwards compatibility for tvOS versions before 16.0
7
+ struct SelectiveRoundedRectangle: Shape {
8
+ var topLeadingRadius: CGFloat
9
+ var topTrailingRadius: CGFloat
10
+ var bottomLeadingRadius: CGFloat
11
+ var bottomTrailingRadius: CGFloat
12
+
13
+ func path(in rect: CGRect) -> Path {
14
+ var path = Path()
15
+
16
+ let tl = min(topLeadingRadius, min(rect.width, rect.height) / 2)
17
+ let tr = min(topTrailingRadius, min(rect.width, rect.height) / 2)
18
+ let bl = min(bottomLeadingRadius, min(rect.width, rect.height) / 2)
19
+ let br = min(bottomTrailingRadius, min(rect.width, rect.height) / 2)
20
+
21
+ path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
22
+ path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
23
+ path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr),
24
+ radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
25
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
26
+ path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br),
27
+ radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
28
+ path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
29
+ path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl),
30
+ radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
31
+ path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
32
+ path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl),
33
+ radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
34
+ path.closeSubpath()
35
+
36
+ return path
37
+ }
38
+ }
39
+
40
+ struct SearchResultCard: View {
41
+ let item: SearchResultItem
42
+ let showTitle: Bool
43
+ let showSubtitle: Bool
44
+ let showFocusBorder: Bool
45
+ let showTitleOverlay: Bool
46
+ let enableMarquee: Bool
47
+ let marqueeDelay: Double
48
+ let textColor: Color?
49
+ let accentColor: Color
50
+ let cardWidth: CGFloat
51
+ let cardHeight: CGFloat
52
+ let imageContentMode: ContentMode
53
+ let cardPadding: CGFloat
54
+ let overlayTitleSize: CGFloat
55
+ let onSelect: () -> Void
56
+ @FocusState private var isFocused: Bool
57
+
58
+ private let placeholderColor = Color(white: 0.2)
59
+
60
+ /// Computed shape for the card with selective rounded corners.
61
+ /// Bottom corners are rounded only when no title/subtitle section is displayed.
62
+ private var cardShape: SelectiveRoundedRectangle {
63
+ SelectiveRoundedRectangle(
64
+ topLeadingRadius: 12,
65
+ topTrailingRadius: 12,
66
+ bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
67
+ bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
68
+ )
69
+ }
70
+
71
+ // Title overlay height
72
+ private var overlayHeight: CGFloat { cardHeight * 0.25 } // 25% of card
73
+
74
+ var body: some View {
75
+ Button(action: onSelect) {
76
+ VStack(alignment: .leading, spacing: showTitle || showSubtitle ? 12 : 0) {
77
+ ZStack(alignment: .bottom) {
78
+ // Card image content
79
+ ZStack {
80
+ placeholderColor
81
+
82
+ if let imageUrl = item.imageUrl, let url = URL(string: imageUrl) {
83
+ CachedAsyncImage(
84
+ url: url,
85
+ contentMode: imageContentMode,
86
+ width: cardWidth,
87
+ height: cardHeight
88
+ )
89
+ } else {
90
+ placeholderIcon
91
+ }
92
+ }
93
+ .frame(width: cardWidth, height: cardHeight)
94
+ .clipped()
95
+
96
+ // Title overlay with native material blur
97
+ if showTitleOverlay {
98
+ ZStack {
99
+ Rectangle()
100
+ .fill(.ultraThinMaterial)
101
+ .frame(width: cardWidth, height: overlayHeight)
102
+
103
+ if enableMarquee {
104
+ MarqueeText(
105
+ item.title,
106
+ font: .system(size: overlayTitleSize, weight: .semibold),
107
+ leftFade: 12,
108
+ rightFade: 12,
109
+ startDelay: marqueeDelay,
110
+ animate: isFocused
111
+ )
112
+ .foregroundColor(.white)
113
+ .padding(.horizontal, cardPadding)
114
+ } else {
115
+ Text(item.title)
116
+ .font(.system(size: overlayTitleSize, weight: .semibold))
117
+ .foregroundColor(.white)
118
+ .lineLimit(2)
119
+ .multilineTextAlignment(.center)
120
+ .padding(.horizontal, cardPadding)
121
+ }
122
+ }
123
+ .frame(width: cardWidth, height: overlayHeight)
124
+ }
125
+ }
126
+ .frame(width: cardWidth, height: cardHeight)
127
+ .clipShape(cardShape)
128
+ .overlay(
129
+ cardShape.stroke(showFocusBorder && isFocused ? accentColor : Color.clear, lineWidth: 4)
130
+ )
131
+
132
+ if showTitle || showSubtitle {
133
+ VStack(alignment: .leading, spacing: 4) {
134
+ if showTitle {
135
+ Text(item.title)
136
+ .font(.callout)
137
+ .fontWeight(.medium)
138
+ .lineLimit(2)
139
+ .multilineTextAlignment(.leading)
140
+ .foregroundColor(.primary)
141
+ }
142
+
143
+ if showSubtitle, let subtitle = item.subtitle {
144
+ Text(subtitle)
145
+ .font(.caption)
146
+ .foregroundColor(textColor ?? .secondary)
147
+ .lineLimit(1)
148
+ }
149
+ }
150
+ .padding(cardPadding)
151
+ .frame(width: cardWidth, alignment: .leading)
152
+ }
153
+ }
154
+ }
155
+ .buttonStyle(.card)
156
+ .focused($isFocused)
157
+ }
158
+
159
+ private var placeholderIcon: some View {
160
+ ZStack {
161
+ Circle()
162
+ .fill(Color.white.opacity(0.1))
163
+ .frame(width: 120, height: 120)
164
+
165
+ Image(systemName: "photo")
166
+ .font(.system(size: 60, weight: .light))
167
+ .foregroundColor(.white.opacity(0.7))
168
+ }
169
+ }
170
+ }
171
+
172
+ #endif
@@ -0,0 +1,8 @@
1
+ import Foundation
2
+
3
+ struct SearchResultItem: Identifiable, Equatable {
4
+ let id: String
5
+ let title: String
6
+ let subtitle: String?
7
+ let imageUrl: String?
8
+ }
@@ -0,0 +1,183 @@
1
+ import XCTest
2
+
3
+ /// Unit tests for HexColorParser
4
+ final class HexColorParserTests: XCTestCase {
5
+
6
+ // MARK: - 6-character hex (RRGGBB)
7
+
8
+ func testParse_6charWithHash_white() {
9
+ let rgba = HexColorParser.parse("#FFFFFF")
10
+ XCTAssertNotNil(rgba)
11
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
12
+ XCTAssertEqual(rgba!.green, 1.0, accuracy: 0.001)
13
+ XCTAssertEqual(rgba!.blue, 1.0, accuracy: 0.001)
14
+ XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
15
+ }
16
+
17
+ func testParse_6charWithHash_black() {
18
+ let rgba = HexColorParser.parse("#000000")
19
+ XCTAssertNotNil(rgba)
20
+ XCTAssertEqual(rgba!.red, 0.0, accuracy: 0.001)
21
+ XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
22
+ XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
23
+ XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
24
+ }
25
+
26
+ func testParse_6charWithHash_red() {
27
+ let rgba = HexColorParser.parse("#FF0000")
28
+ XCTAssertNotNil(rgba)
29
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
30
+ XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
31
+ XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
32
+ }
33
+
34
+ func testParse_6charWithoutHash() {
35
+ let rgba = HexColorParser.parse("FFC312")
36
+ XCTAssertNotNil(rgba)
37
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
38
+ XCTAssertEqual(rgba!.green, 0.765, accuracy: 0.001)
39
+ XCTAssertEqual(rgba!.blue, Double(0x12) / 255, accuracy: 0.001)
40
+ }
41
+
42
+ func testParse_6charLowercase() {
43
+ let rgba = HexColorParser.parse("#ff5733")
44
+ XCTAssertNotNil(rgba)
45
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
46
+ XCTAssertEqual(rgba!.green, Double(0x57) / 255, accuracy: 0.001)
47
+ XCTAssertEqual(rgba!.blue, Double(0x33) / 255, accuracy: 0.001)
48
+ }
49
+
50
+ // MARK: - 3-character hex (RGB shorthand)
51
+
52
+ func testParse_3charWithHash_white() {
53
+ let rgba = HexColorParser.parse("#FFF")
54
+ XCTAssertNotNil(rgba)
55
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
56
+ XCTAssertEqual(rgba!.green, 1.0, accuracy: 0.001)
57
+ XCTAssertEqual(rgba!.blue, 1.0, accuracy: 0.001)
58
+ XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
59
+ }
60
+
61
+ func testParse_3charWithHash_black() {
62
+ let rgba = HexColorParser.parse("#000")
63
+ XCTAssertNotNil(rgba)
64
+ XCTAssertEqual(rgba!.red, 0.0, accuracy: 0.001)
65
+ XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
66
+ XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
67
+ }
68
+
69
+ func testParse_3char_expandsCorrectly() {
70
+ // #F80 should expand to #FF8800
71
+ let rgba = HexColorParser.parse("#F80")
72
+ XCTAssertNotNil(rgba)
73
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
74
+ XCTAssertEqual(rgba!.green, Double(0x88) / 255, accuracy: 0.001)
75
+ XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
76
+ }
77
+
78
+ // MARK: - 8-character hex (AARRGGBB)
79
+
80
+ func testParse_8charWithHash_fullAlpha() {
81
+ let rgba = HexColorParser.parse("#FFFF0000")
82
+ XCTAssertNotNil(rgba)
83
+ XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
84
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
85
+ XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
86
+ XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
87
+ }
88
+
89
+ func testParse_8charWithHash_halfAlpha() {
90
+ let rgba = HexColorParser.parse("#80FF0000")
91
+ XCTAssertNotNil(rgba)
92
+ XCTAssertEqual(rgba!.alpha, Double(0x80) / 255, accuracy: 0.001)
93
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
94
+ }
95
+
96
+ func testParse_8charWithHash_zeroAlpha() {
97
+ let rgba = HexColorParser.parse("#00FFFFFF")
98
+ XCTAssertNotNil(rgba)
99
+ XCTAssertEqual(rgba!.alpha, 0.0, accuracy: 0.001)
100
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
101
+ XCTAssertEqual(rgba!.green, 1.0, accuracy: 0.001)
102
+ XCTAssertEqual(rgba!.blue, 1.0, accuracy: 0.001)
103
+ }
104
+
105
+ // MARK: - Invalid inputs
106
+
107
+ func testParse_emptyString_returnsNil() {
108
+ XCTAssertNil(HexColorParser.parse(""))
109
+ }
110
+
111
+ func testParse_hashOnly_returnsNil() {
112
+ XCTAssertNil(HexColorParser.parse("#"))
113
+ }
114
+
115
+ func testParse_invalidHexChars_returnsNil() {
116
+ XCTAssertNil(HexColorParser.parse("#GGGGGG"))
117
+ }
118
+
119
+ func testParse_wrongLength_4chars_returnsNil() {
120
+ XCTAssertNil(HexColorParser.parse("#ABCD"))
121
+ }
122
+
123
+ func testParse_wrongLength_5chars_returnsNil() {
124
+ XCTAssertNil(HexColorParser.parse("#ABCDE"))
125
+ }
126
+
127
+ func testParse_wrongLength_7chars_returnsNil() {
128
+ XCTAssertNil(HexColorParser.parse("#ABCDEFF"))
129
+ }
130
+
131
+ func testParse_tooLongString_returnsNil() {
132
+ let longString = String(repeating: "A", count: 21)
133
+ XCTAssertNil(HexColorParser.parse(longString))
134
+ }
135
+
136
+ func testParse_maxLengthString_stillParsed() {
137
+ // 20 chars is at the limit. After stripping "#", we get 19 chars which is > 8, so nil.
138
+ let atLimit = "#" + String(repeating: "F", count: 19)
139
+ XCTAssertEqual(atLimit.count, 20)
140
+ XCTAssertNil(HexColorParser.parse(atLimit)) // 19 hex chars is not 3, 6, or 8
141
+ }
142
+
143
+ func testParse_exactlyMaxLength_valid8char() {
144
+ // A valid 8-char hex within the 20-char limit
145
+ let valid = "#FFAABBCC"
146
+ XCTAssertEqual(valid.count, 9) // well under 20
147
+ XCTAssertNotNil(HexColorParser.parse(valid))
148
+ }
149
+
150
+ // MARK: - Edge cases
151
+
152
+ func testParse_mixedCase() {
153
+ let rgba = HexColorParser.parse("#aAbBcC")
154
+ XCTAssertNotNil(rgba)
155
+ XCTAssertEqual(rgba!.red, Double(0xAA) / 255, accuracy: 0.001)
156
+ XCTAssertEqual(rgba!.green, Double(0xBB) / 255, accuracy: 0.001)
157
+ XCTAssertEqual(rgba!.blue, Double(0xCC) / 255, accuracy: 0.001)
158
+ }
159
+
160
+ func testParse_defaultAccentColor_FFC312() {
161
+ // This is the actual default accent color used by the library
162
+ let rgba = HexColorParser.parse("#FFC312")
163
+ XCTAssertNotNil(rgba)
164
+ XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
165
+ XCTAssertEqual(rgba!.green, 0.765, accuracy: 0.001)
166
+ XCTAssertEqual(rgba!.blue, Double(0x12) / 255, accuracy: 0.001)
167
+ XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
168
+ }
169
+
170
+ // MARK: - RGBA Equatable conformance
171
+
172
+ func testRGBA_equality() {
173
+ let a = HexColorParser.RGBA(red: 1.0, green: 0.5, blue: 0.0, alpha: 1.0)
174
+ let b = HexColorParser.RGBA(red: 1.0, green: 0.5, blue: 0.0, alpha: 1.0)
175
+ XCTAssertEqual(a, b)
176
+ }
177
+
178
+ func testRGBA_inequality() {
179
+ let a = HexColorParser.RGBA(red: 1.0, green: 0.5, blue: 0.0, alpha: 1.0)
180
+ let b = HexColorParser.RGBA(red: 0.0, green: 0.5, blue: 0.0, alpha: 1.0)
181
+ XCTAssertNotEqual(a, b)
182
+ }
183
+ }
@@ -35,7 +35,7 @@ final class SearchViewModelTests: XCTestCase {
35
35
  }
36
36
 
37
37
  func testInitialState_defaultPlaceholder() {
38
- XCTAssertEqual(viewModel.placeholder, "Search movies and videos...")
38
+ XCTAssertEqual(viewModel.placeholder, "Search...")
39
39
  }
40
40
 
41
41
  func testInitialState_showTitleFalse() {
@@ -0,0 +1,125 @@
1
+ #if os(tvOS)
2
+
3
+ import SwiftUI
4
+
5
+ struct TvosSearchContentView: View {
6
+ @ObservedObject var viewModel: SearchViewModel
7
+
8
+ private var gridColumns: [GridItem] {
9
+ Array(repeating: GridItem(.flexible(), spacing: viewModel.cardMargin), count: viewModel.columns)
10
+ }
11
+
12
+ var body: some View {
13
+ NavigationView {
14
+ ZStack {
15
+ Group {
16
+ if viewModel.results.isEmpty && viewModel.searchText.isEmpty {
17
+ emptyStateView
18
+ } else if viewModel.results.isEmpty && !viewModel.searchText.isEmpty {
19
+ if viewModel.isLoading {
20
+ searchingStateView
21
+ } else {
22
+ noResultsView
23
+ }
24
+ } else {
25
+ resultsGridView
26
+ }
27
+ }
28
+
29
+ // Loading overlay when loading with results
30
+ if viewModel.isLoading && !viewModel.results.isEmpty {
31
+ loadingOverlay
32
+ }
33
+ }
34
+ .searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
35
+ .onChange(of: viewModel.searchText) { newValue in
36
+ viewModel.onSearch?(newValue)
37
+ }
38
+ }
39
+ .padding(.top, viewModel.topInset)
40
+ .ignoresSafeArea(.all, edges: .top)
41
+ }
42
+
43
+ private var emptyStateView: some View {
44
+ VStack(spacing: 20) {
45
+ Image(systemName: "magnifyingglass")
46
+ .font(.system(size: 80))
47
+ .foregroundColor(viewModel.textColor ?? .secondary)
48
+ Text(viewModel.emptyStateText)
49
+ .font(.headline)
50
+ .foregroundColor(viewModel.textColor ?? .secondary)
51
+ }
52
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
53
+ }
54
+
55
+ private var searchingStateView: some View {
56
+ VStack(spacing: 20) {
57
+ ProgressView()
58
+ .scaleEffect(1.5)
59
+ Text(viewModel.searchingText)
60
+ .font(.headline)
61
+ .foregroundColor(viewModel.textColor ?? .secondary)
62
+ }
63
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
64
+ }
65
+
66
+ private var noResultsView: some View {
67
+ VStack(spacing: 20) {
68
+ Image(systemName: "film.stack")
69
+ .font(.system(size: 80))
70
+ .foregroundColor(viewModel.textColor ?? .secondary)
71
+ Text(viewModel.noResultsText)
72
+ .font(.headline)
73
+ .foregroundColor(viewModel.textColor ?? .secondary)
74
+ Text(viewModel.noResultsHintText)
75
+ .font(.subheadline)
76
+ .foregroundColor(viewModel.textColor ?? .secondary)
77
+ }
78
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
79
+ }
80
+
81
+ private var loadingOverlay: some View {
82
+ VStack {
83
+ HStack {
84
+ Spacer()
85
+ ProgressView()
86
+ .padding(16)
87
+ .background(Color.black.opacity(0.6))
88
+ .clipShape(RoundedRectangle(cornerRadius: 12))
89
+ }
90
+ .padding(.trailing, 60)
91
+ .padding(.top, 20)
92
+ Spacer()
93
+ }
94
+ }
95
+
96
+ private var resultsGridView: some View {
97
+ ScrollView {
98
+ LazyVGrid(columns: gridColumns, spacing: viewModel.cardMargin) {
99
+ ForEach(viewModel.results) { item in
100
+ SearchResultCard(
101
+ item: item,
102
+ showTitle: viewModel.showTitle,
103
+ showSubtitle: viewModel.showSubtitle,
104
+ showFocusBorder: viewModel.showFocusBorder,
105
+ showTitleOverlay: viewModel.showTitleOverlay,
106
+ enableMarquee: viewModel.enableMarquee,
107
+ marqueeDelay: viewModel.marqueeDelay,
108
+ textColor: viewModel.textColor,
109
+ accentColor: viewModel.accentColor,
110
+ cardWidth: viewModel.cardWidth,
111
+ cardHeight: viewModel.cardHeight,
112
+ imageContentMode: viewModel.imageContentMode,
113
+ cardPadding: viewModel.cardPadding,
114
+ overlayTitleSize: viewModel.overlayTitleSize,
115
+ onSelect: { viewModel.onSelectItem?(item.id) }
116
+ )
117
+ }
118
+ }
119
+ .padding(.horizontal, 60)
120
+ .padding(.vertical, 40)
121
+ }
122
+ }
123
+ }
124
+
125
+ #endif
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-tvos-search",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Native tvOS search view using SwiftUI .searchable modifier for Expo/React Native",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -73,6 +73,7 @@
73
73
  "ios",
74
74
  "expo-module.config.json",
75
75
  "README.md",
76
+ "CHANGELOG.md",
76
77
  "LICENSE"
77
78
  ],
78
79
  "engines": {