Initial commit

Seto Elkahfi committed May 18, 2026 at 19:40 UTC 80bc31f1280ba87a40c220e4bc25f478945b02ec
58 files changed +6031
.gitignore new
+9
@@ -0,0 +1,9 @@
1 +.DS_Store
2 +/.build/
3 +/Example/.build/
4 +/Packages
5 +xcuserdata/
6 +DerivedData/
7 +.swiftpm/configuration/registries.json
8 +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 +.netrc
.skills/humanizer/SKILL.md new
+559
@@ -0,0 +1,559 @@
1 +---
2 +name: humanizer
3 +version: 2.5.1
4 +description: |
5 + Remove signs of AI-generated writing from text. Use when editing or reviewing
6 + text to make it sound more natural and human-written. Based on Wikipedia's
7 + comprehensive "Signs of AI writing" guide. Detects and fixes patterns including:
8 + inflated symbolism, promotional language, superficial -ing analyses, vague
9 + attributions, em dash overuse, rule of three, AI vocabulary words, passive
10 + voice, negative parallelisms, and filler phrases.
11 +license: MIT
12 +compatibility: claude-code opencode
13 +allowed-tools:
14 + - Read
15 + - Write
16 + - Edit
17 + - Grep
18 + - Glob
19 + - AskUserQuestion
20 +---
21 +
22 +# Humanizer: Remove AI Writing Patterns
23 +
24 +You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup.
25 +
26 +## Your Task
27 +
28 +When given text to humanize:
29 +
30 +1. **Identify AI patterns** - Scan for the patterns listed below
31 +2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives
32 +3. **Preserve meaning** - Keep the core message intact
33 +4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.)
34 +5. **Add soul** - Don't just remove bad patterns; inject actual personality
35 +6. **Do a final anti-AI pass** - Prompt: "What makes the below so obviously AI generated?" Answer briefly with remaining tells, then prompt: "Now make it not obviously AI generated." and revise
36 +
37 +
38 +## Voice Calibration (Optional)
39 +
40 +If the user provides a writing sample (their own previous writing), analyze it before rewriting:
41 +
42 +1. **Read the sample first.** Note:
43 + - Sentence length patterns (short and punchy? Long and flowing? Mixed?)
44 + - Word choice level (casual? academic? somewhere between?)
45 + - How they start paragraphs (jump right in? Set context first?)
46 + - Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?)
47 + - Any recurring phrases or verbal tics
48 + - How they handle transitions (explicit connectors? Just start the next point?)
49 +
50 +2. **Match their voice in the rewrite.** Don't just remove AI patterns - replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components."
51 +
52 +3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below).
53 +
54 +### How to provide a sample
55 +- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]"
56 +- File: "Humanize this text. Use my writing style from [file path] as a reference."
57 +
58 +
59 +## PERSONALITY AND SOUL
60 +
61 +Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.
62 +
63 +### Signs of soulless writing (even if technically "clean"):
64 +- Every sentence is the same length and structure
65 +- No opinions, just neutral reporting
66 +- No acknowledgment of uncertainty or mixed feelings
67 +- No first-person perspective when appropriate
68 +- No humor, no edge, no personality
69 +- Reads like a Wikipedia article or press release
70 +
71 +### How to add voice:
72 +
73 +**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
74 +
75 +**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
76 +
77 +**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
78 +
79 +**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
80 +
81 +**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
82 +
83 +**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
84 +
85 +### Before (clean but soulless):
86 +> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
87 +
88 +### After (has a pulse):
89 +> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night.
90 +
91 +
92 +## CONTENT PATTERNS
93 +
94 +### 1. Undue Emphasis on Significance, Legacy, and Broader Trends
95 +
96 +**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted
97 +
98 +**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.
99 +
100 +**Before:**
101 +> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.
102 +
103 +**After:**
104 +> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.
105 +
106 +
107 +### 2. Undue Emphasis on Notability and Media Coverage
108 +
109 +**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence
110 +
111 +**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.
112 +
113 +**Before:**
114 +> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.
115 +
116 +**After:**
117 +> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
118 +
119 +
120 +### 3. Superficial Analyses with -ing Endings
121 +
122 +**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...
123 +
124 +**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.
125 +
126 +**Before:**
127 +> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.
128 +
129 +**After:**
130 +> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.
131 +
132 +
133 +### 4. Promotional and Advertisement-like Language
134 +
135 +**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning
136 +
137 +**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.
138 +
139 +**Before:**
140 +> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.
141 +
142 +**After:**
143 +> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.
144 +
145 +
146 +### 5. Vague Attributions and Weasel Words
147 +
148 +**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)
149 +
150 +**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.
151 +
152 +**Before:**
153 +> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.
154 +
155 +**After:**
156 +> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
157 +
158 +
159 +### 6. Outline-like "Challenges and Future Prospects" Sections
160 +
161 +**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
162 +
163 +**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.
164 +
165 +**Before:**
166 +> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.
167 +
168 +**After:**
169 +> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.
170 +
171 +
172 +## LANGUAGE AND GRAMMAR PATTERNS
173 +
174 +### 7. Overused "AI Vocabulary" Words
175 +
176 +**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant
177 +
178 +**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.
179 +
180 +**Before:**
181 +> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.
182 +
183 +**After:**
184 +> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
185 +
186 +
187 +### 8. Avoidance of "is"/"are" (Copula Avoidance)
188 +
189 +**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
190 +
191 +**Problem:** LLMs substitute elaborate constructions for simple copulas.
192 +
193 +**Before:**
194 +> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.
195 +
196 +**After:**
197 +> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.
198 +
199 +
200 +### 9. Negative Parallelisms and Tailing Negations
201 +
202 +**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause.
203 +
204 +**Before:**
205 +> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.
206 +
207 +**After:**
208 +> The heavy beat adds to the aggressive tone.
209 +
210 +**Before (tailing negation):**
211 +> The options come from the selected item, no guessing.
212 +
213 +**After:**
214 +> The options come from the selected item without forcing the user to guess.
215 +
216 +
217 +### 10. Rule of Three Overuse
218 +
219 +**Problem:** LLMs force ideas into groups of three to appear comprehensive.
220 +
221 +**Before:**
222 +> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
223 +
224 +**After:**
225 +> The event includes talks and panels. There's also time for informal networking between sessions.
226 +
227 +
228 +### 11. Elegant Variation (Synonym Cycling)
229 +
230 +**Problem:** AI has repetition-penalty code causing excessive synonym substitution.
231 +
232 +**Before:**
233 +> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
234 +
235 +**After:**
236 +> The protagonist faces many challenges but eventually triumphs and returns home.
237 +
238 +
239 +### 12. False Ranges
240 +
241 +**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.
242 +
243 +**Before:**
244 +> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.
245 +
246 +**After:**
247 +> The book covers the Big Bang, star formation, and current theories about dark matter.
248 +
249 +
250 +### 13. Passive Voice and Subjectless Fragments
251 +
252 +**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct.
253 +
254 +**Before:**
255 +> No configuration file needed. The results are preserved automatically.
256 +
257 +**After:**
258 +> You do not need a configuration file. The system preserves the results automatically.
259 +
260 +
261 +## STYLE PATTERNS
262 +
263 +### 14. Em Dash Overuse
264 +
265 +**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses.
266 +
267 +**Before:**
268 +> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.
269 +
270 +**After:**
271 +> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents.
272 +
273 +
274 +### 15. Overuse of Boldface
275 +
276 +**Problem:** AI chatbots emphasize phrases in boldface mechanically.
277 +
278 +**Before:**
279 +> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.
280 +
281 +**After:**
282 +> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
283 +
284 +
285 +### 16. Inline-Header Vertical Lists
286 +
287 +**Problem:** AI outputs lists where items start with bolded headers followed by colons.
288 +
289 +**Before:**
290 +> - **User Experience:** The user experience has been significantly improved with a new interface.
291 +> - **Performance:** Performance has been enhanced through optimized algorithms.
292 +> - **Security:** Security has been strengthened with end-to-end encryption.
293 +
294 +**After:**
295 +> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.
296 +
297 +
298 +### 17. Title Case in Headings
299 +
300 +**Problem:** AI chatbots capitalize all main words in headings.
301 +
302 +**Before:**
303 +> ## Strategic Negotiations And Global Partnerships
304 +
305 +**After:**
306 +> ## Strategic negotiations and global partnerships
307 +
308 +
309 +### 18. Emojis
310 +
311 +**Problem:** AI chatbots often decorate headings or bullet points with emojis.
312 +
313 +**Before:**
314 +> 🚀 **Launch Phase:** The product launches in Q3
315 +> 💡 **Key Insight:** Users prefer simplicity
316 +> ✅ **Next Steps:** Schedule follow-up meeting
317 +
318 +**After:**
319 +> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.
320 +
321 +
322 +### 19. Curly Quotation Marks
323 +
324 +**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("...").
325 +
326 +**Before:**
327 +> He said “the project is on track” but others disagreed.
328 +
329 +**After:**
330 +> He said "the project is on track" but others disagreed.
331 +
332 +
333 +## COMMUNICATION PATTERNS
334 +
335 +### 20. Collaborative Communication Artifacts
336 +
337 +**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
338 +
339 +**Problem:** Text meant as chatbot correspondence gets pasted as content.
340 +
341 +**Before:**
342 +> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
343 +
344 +**After:**
345 +> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
346 +
347 +
348 +### 21. Knowledge-Cutoff Disclaimers
349 +
350 +**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
351 +
352 +**Problem:** AI disclaimers about incomplete information get left in text.
353 +
354 +**Before:**
355 +> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
356 +
357 +**After:**
358 +> The company was founded in 1994, according to its registration documents.
359 +
360 +
361 +### 22. Sycophantic/Servile Tone
362 +
363 +**Problem:** Overly positive, people-pleasing language.
364 +
365 +**Before:**
366 +> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.
367 +
368 +**After:**
369 +> The economic factors you mentioned are relevant here.
370 +
371 +
372 +## FILLER AND HEDGING
373 +
374 +### 23. Filler Phrases
375 +
376 +**Before → After:**
377 +- "In order to achieve this goal" → "To achieve this"
378 +- "Due to the fact that it was raining" → "Because it was raining"
379 +- "At this point in time" → "Now"
380 +- "In the event that you need help" → "If you need help"
381 +- "The system has the ability to process" → "The system can process"
382 +- "It is important to note that the data shows" → "The data shows"
383 +
384 +
385 +### 24. Excessive Hedging
386 +
387 +**Problem:** Over-qualifying statements.
388 +
389 +**Before:**
390 +> It could potentially possibly be argued that the policy might have some effect on outcomes.
391 +
392 +**After:**
393 +> The policy may affect outcomes.
394 +
395 +
396 +### 25. Generic Positive Conclusions
397 +
398 +**Problem:** Vague upbeat endings.
399 +
400 +**Before:**
401 +> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.
402 +
403 +**After:**
404 +> The company plans to open two more locations next year.
405 +
406 +
407 +### 26. Hyphenated Word Pair Overuse
408 +
409 +**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end
410 +
411 +**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate.
412 +
413 +**Before:**
414 +> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented.
415 +
416 +**After:**
417 +> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented.
418 +
419 +
420 +### 27. Persuasive Authority Tropes
421 +
422 +**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter
423 +
424 +**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony.
425 +
426 +**Before:**
427 +> The real question is whether teams can adapt. At its core, what really matters is organizational readiness.
428 +
429 +**After:**
430 +> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits.
431 +
432 +
433 +### 28. Signposting and Announcements
434 +
435 +**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado
436 +
437 +**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel.
438 +
439 +**Before:**
440 +> Let's dive into how caching works in Next.js. Here's what you need to know.
441 +
442 +**After:**
443 +> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache.
444 +
445 +
446 +### 29. Fragmented Headers
447 +
448 +**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins.
449 +
450 +**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded.
451 +
452 +**Before:**
453 +> ## Performance
454 +>
455 +> Speed matters.
456 +>
457 +> When users hit a slow page, they leave.
458 +
459 +**After:**
460 +> ## Performance
461 +>
462 +> When users hit a slow page, they leave.
463 +
464 +---
465 +
466 +## Process
467 +
468 +1. Read the input text carefully
469 +2. Identify all instances of the patterns above
470 +3. Rewrite each problematic section
471 +4. Ensure the revised text:
472 + - Sounds natural when read aloud
473 + - Varies sentence structure naturally
474 + - Uses specific details over vague claims
475 + - Maintains appropriate tone for context
476 + - Uses simple constructions (is/are/has) where appropriate
477 +5. Present a draft humanized version
478 +6. Prompt: "What makes the below so obviously AI generated?"
479 +7. Answer briefly with the remaining tells (if any)
480 +8. Prompt: "Now make it not obviously AI generated."
481 +9. Present the final version (revised after the audit)
482 +
483 +## Output Format
484 +
485 +Provide:
486 +1. Draft rewrite
487 +2. "What makes the below so obviously AI generated?" (brief bullets)
488 +3. Final rewrite
489 +4. A brief summary of changes made (optional, if helpful)
490 +
491 +
492 +## Full Example
493 +
494 +**Before (AI-sounding):**
495 +> Great question! Here is an essay on this topic. I hope this helps!
496 +>
497 +> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
498 +>
499 +> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
500 +>
501 +> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
502 +>
503 +> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
504 +> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
505 +> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
506 +>
507 +> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
508 +>
509 +> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you’d like me to expand on any section!
510 +
511 +**Draft rewrite:**
512 +> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
513 +>
514 +> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
515 +>
516 +> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
517 +>
518 +> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
519 +>
520 +> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.
521 +
522 +**What makes the below so obviously AI generated?**
523 +- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs).
524 +- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced.
525 +- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking.
526 +
527 +**Now make it not obviously AI generated.**
528 +> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
529 +>
530 +> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
531 +>
532 +> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable.
533 +>
534 +> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing.
535 +
536 +**Changes made:**
537 +- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
538 +- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
539 +- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
540 +- Removed vague attributions ("Industry observers")
541 +- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
542 +- Removed negative parallelism ("It's not just X; it's Y")
543 +- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
544 +- Removed false ranges ("from X to Y, from A to B")
545 +- Removed em dashes, emojis, boldface headers, and curly quotes
546 +- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
547 +- Removed formulaic challenges section ("Despite challenges... continues to thrive")
548 +- Removed knowledge-cutoff hedging ("While specific details are limited...")
549 +- Removed excessive hedging ("could potentially be argued that... might have some")
550 +- Removed filler phrases and persuasive framing ("In order to", "At its core")
551 +- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
552 +- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders)
553 +
554 +
555 +## Reference
556 +
557 +This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.
558 +
559 +Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
Example/ExampleApp/App/AppDelegate.swift new
+29
@@ -0,0 +1,29 @@
1 +//
2 +// AppDelegate.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +@main
12 +class AppDelegate: UIResponder, UIApplicationDelegate {
13 +
14 + func application(
15 + _ application: UIApplication,
16 + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
17 + ) -> Bool {
18 + true
19 + }
20 +
21 + func application(
22 + _ application: UIApplication,
23 + configurationForConnecting connectingSceneSession: UISceneSession,
24 + options: UIScene.ConnectionOptions
25 + ) -> UISceneConfiguration {
26 + UISceneConfiguration(
27 + name: "Default Configuration", sessionRole: connectingSceneSession.role)
28 + }
29 +}
Example/ExampleApp/App/SceneDelegate.swift new
+29
@@ -0,0 +1,29 @@
1 +//
2 +// SceneDelegate.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +class SceneDelegate: UIResponder, UIWindowSceneDelegate {
12 +
13 + var window: UIWindow?
14 +
15 + func scene(
16 + _ scene: UIScene,
17 + willConnectTo session: UISceneSession,
18 + options connectionOptions: UIScene.ConnectionOptions
19 + ) {
20 + guard let windowScene = scene as? UIWindowScene else { return }
21 + let window = UIWindow(windowScene: windowScene)
22 + let homeVC = StreamingHomeViewController()
23 + let nav = UINavigationController(rootViewController: homeVC)
24 + nav.navigationBar.prefersLargeTitles = true
25 + window.rootViewController = nav
26 + window.makeKeyAndVisible()
27 + self.window = window
28 + }
29 +}
Example/ExampleApp/Cells/CategoryCell.swift new
+69
@@ -0,0 +1,69 @@
1 +//
2 +// CategoryCell.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +final class CategoryCell: UICollectionViewCell, JomloSelfConfiguringCell {
13 +
14 + private let iconLabel: UILabel = {
15 + let label = UILabel()
16 + label.font = .systemFont(ofSize: 32)
17 + label.textAlignment = .center
18 + return label
19 + }()
20 +
21 + private let nameLabel: UILabel = {
22 + let label = UILabel()
23 + label.font = .preferredFont(forTextStyle: .caption1)
24 + label.adjustsFontForContentSizeCategory = true
25 + label.textColor = .white
26 + label.textAlignment = .center
27 + return label
28 + }()
29 +
30 + override init(frame: CGRect) {
31 + super.init(frame: frame)
32 + setupViews()
33 + }
34 +
35 + required init?(coder: NSCoder) {
36 + fatalError("init(coder:) not supported")
37 + }
38 +
39 + func configure(with item: CategoryItem) {
40 + iconLabel.text = item.iconName
41 + nameLabel.text = item.name
42 + contentView.backgroundColor = UIColor(hex: item.colorHex) ?? .systemGray
43 + }
44 +
45 + private func setupViews() {
46 + contentView.layer.cornerRadius = 12
47 + contentView.clipsToBounds = true
48 +
49 + let stack = UIStackView(arrangedSubviews: [iconLabel, nameLabel])
50 + stack.axis = .vertical
51 + stack.spacing = 4
52 + stack.alignment = .center
53 + stack.translatesAutoresizingMaskIntoConstraints = false
54 +
55 + contentView.addSubview(stack)
56 + NSLayoutConstraint.activate([
57 + stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
58 + stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
59 + ])
60 +
61 + isAccessibilityElement = true
62 + accessibilityTraits = .button
63 + }
64 +
65 + override var accessibilityLabel: String? {
66 + get { nameLabel.text }
67 + set {}
68 + }
69 +}
Example/ExampleApp/Cells/ContinueWatchingCell.swift new
+88
@@ -0,0 +1,88 @@
1 +//
2 +// ContinueWatchingCell.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +final class ContinueWatchingCell: UICollectionViewCell, JomloSelfConfiguringCell {
13 +
14 + private let thumbnailView: UIView = {
15 + let view = UIView()
16 + view.layer.cornerRadius = 8
17 + view.clipsToBounds = true
18 + return view
19 + }()
20 +
21 + private let titleLabel: UILabel = {
22 + let label = UILabel()
23 + label.font = .preferredFont(forTextStyle: .subheadline)
24 + label.adjustsFontForContentSizeCategory = true
25 + label.textColor = .label
26 + return label
27 + }()
28 +
29 + private let episodeLabel: UILabel = {
30 + let label = UILabel()
31 + label.font = .preferredFont(forTextStyle: .caption1)
32 + label.adjustsFontForContentSizeCategory = true
33 + label.textColor = .secondaryLabel
34 + return label
35 + }()
36 +
37 + private let progressView: UIProgressView = {
38 + let progress = UIProgressView(progressViewStyle: .default)
39 + progress.trackTintColor = .darkGray
40 + progress.progressTintColor = .systemRed
41 + return progress
42 + }()
43 +
44 + override init(frame: CGRect) {
45 + super.init(frame: frame)
46 + setupViews()
47 + }
48 +
49 + required init?(coder: NSCoder) {
50 + fatalError("init(coder:) not supported")
51 + }
52 +
53 + func configure(with item: ContinueWatchingItem) {
54 + titleLabel.text = item.title
55 + episodeLabel.text = item.episodeInfo
56 + progressView.progress = item.progress
57 + thumbnailView.backgroundColor = UIColor(hex: item.colorHex) ?? .gray
58 + }
59 +
60 + private func setupViews() {
61 + let textStack = UIStackView(arrangedSubviews: [titleLabel, episodeLabel])
62 + textStack.axis = .vertical
63 + textStack.spacing = 2
64 +
65 + let mainStack = UIStackView(arrangedSubviews: [thumbnailView, textStack, progressView])
66 + mainStack.axis = .vertical
67 + mainStack.spacing = 6
68 + mainStack.translatesAutoresizingMaskIntoConstraints = false
69 +
70 + contentView.addSubview(mainStack)
71 + NSLayoutConstraint.activate([
72 + mainStack.topAnchor.constraint(equalTo: contentView.topAnchor),
73 + mainStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
74 + mainStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
75 + mainStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor),
76 + thumbnailView.heightAnchor.constraint(
77 + equalTo: thumbnailView.widthAnchor, multiplier: 0.56),
78 + ])
79 +
80 + isAccessibilityElement = true
81 + accessibilityTraits = .button
82 + }
83 +
84 + override var accessibilityLabel: String? {
85 + get { "\(titleLabel.text ?? ""), \(episodeLabel.text ?? "")" }
86 + set {}
87 + }
88 +}
Example/ExampleApp/Cells/HeroCell.swift new
+96
@@ -0,0 +1,96 @@
1 +//
2 +// HeroCell.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +final class HeroCell: UICollectionViewCell, JomloSelfConfiguringCell {
13 +
14 + private let titleLabel: UILabel = {
15 + let label = UILabel()
16 + label.font = .systemFont(ofSize: 28, weight: .bold)
17 + label.textColor = .white
18 + label.numberOfLines = 2
19 + return label
20 + }()
21 +
22 + private let subtitleLabel: UILabel = {
23 + let label = UILabel()
24 + label.font = .systemFont(ofSize: 16, weight: .medium)
25 + label.textColor = .white.withAlphaComponent(0.8)
26 + return label
27 + }()
28 +
29 + private let playButton: UIButton = {
30 + let button = UIButton(type: .system)
31 + button.setTitle("\u{25B6} Play", for: .normal)
32 + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
33 + button.setTitleColor(.black, for: .normal)
34 + button.backgroundColor = .white
35 + button.layer.cornerRadius = 8
36 + button.isUserInteractionEnabled = false
37 + return button
38 + }()
39 +
40 + private let gradientLayer = CAGradientLayer()
41 +
42 + override init(frame: CGRect) {
43 + super.init(frame: frame)
44 + setupViews()
45 + }
46 +
47 + required init?(coder: NSCoder) {
48 + fatalError("init(coder:) not supported")
49 + }
50 +
51 + override func layoutSubviews() {
52 + super.layoutSubviews()
53 + gradientLayer.frame = contentView.bounds
54 + }
55 +
56 + func configure(with item: HeroItem) {
57 + titleLabel.text = item.title
58 + subtitleLabel.text = item.subtitle
59 +
60 + let topColor = UIColor(hex: item.gradientColors.top) ?? .systemBlue
61 + let bottomColor = UIColor(hex: item.gradientColors.bottom) ?? .systemIndigo
62 + gradientLayer.colors = [topColor.cgColor, bottomColor.cgColor]
63 + }
64 +
65 + private func setupViews() {
66 + contentView.layer.cornerRadius = 16
67 + contentView.clipsToBounds = true
68 + contentView.layer.insertSublayer(gradientLayer, at: 0)
69 + gradientLayer.startPoint = CGPoint(x: 0, y: 0)
70 + gradientLayer.endPoint = CGPoint(x: 1, y: 1)
71 +
72 + let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, playButton])
73 + stack.axis = .vertical
74 + stack.spacing = 8
75 + stack.alignment = .leading
76 + stack.translatesAutoresizingMaskIntoConstraints = false
77 +
78 + contentView.addSubview(stack)
79 + NSLayoutConstraint.activate([
80 + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
81 + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
82 + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -24),
83 + playButton.widthAnchor.constraint(equalToConstant: 100),
84 + playButton.heightAnchor.constraint(equalToConstant: 36),
85 + ])
86 +
87 + // Accessibility
88 + isAccessibilityElement = true
89 + accessibilityTraits = .button
90 + }
91 +
92 + override var accessibilityLabel: String? {
93 + get { titleLabel.text }
94 + set {}
95 + }
96 +}
Example/ExampleApp/Cells/PosterCell.swift new
+76
@@ -0,0 +1,76 @@
1 +//
2 +// PosterCell.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +final class PosterCell: UICollectionViewCell, JomloSelfConfiguringCell {
13 +
14 + private let posterView: UIView = {
15 + let view = UIView()
16 + view.layer.cornerRadius = 8
17 + view.clipsToBounds = true
18 + return view
19 + }()
20 +
21 + private let titleLabel: UILabel = {
22 + let label = UILabel()
23 + label.font = .preferredFont(forTextStyle: .caption1)
24 + label.adjustsFontForContentSizeCategory = true
25 + label.textColor = .label
26 + label.numberOfLines = 2
27 + return label
28 + }()
29 +
30 + private let yearLabel: UILabel = {
31 + let label = UILabel()
32 + label.font = .preferredFont(forTextStyle: .caption2)
33 + label.adjustsFontForContentSizeCategory = true
34 + label.textColor = .secondaryLabel
35 + return label
36 + }()
37 +
38 + override init(frame: CGRect) {
39 + super.init(frame: frame)
40 + setupViews()
41 + }
42 +
43 + required init?(coder: NSCoder) {
44 + fatalError("init(coder:) not supported")
45 + }
46 +
47 + func configure(with item: PosterItem) {
48 + titleLabel.text = item.title
49 + yearLabel.text = item.year
50 + posterView.backgroundColor = UIColor(hex: item.colorHex) ?? .gray
51 + }
52 +
53 + private func setupViews() {
54 + let stack = UIStackView(arrangedSubviews: [posterView, titleLabel, yearLabel])
55 + stack.axis = .vertical
56 + stack.spacing = 4
57 + stack.translatesAutoresizingMaskIntoConstraints = false
58 +
59 + contentView.addSubview(stack)
60 + NSLayoutConstraint.activate([
61 + stack.topAnchor.constraint(equalTo: contentView.topAnchor),
62 + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
63 + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
64 + stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor),
65 + posterView.heightAnchor.constraint(equalTo: posterView.widthAnchor, multiplier: 1.5),
66 + ])
67 +
68 + isAccessibilityElement = true
69 + accessibilityTraits = .button
70 + }
71 +
72 + override var accessibilityLabel: String? {
73 + get { "\(titleLabel.text ?? ""), \(yearLabel.text ?? "")" }
74 + set {}
75 + }
76 +}
Example/ExampleApp/Info.plist new
+39
@@ -0,0 +1,39 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<plist version="1.0">
4 +<dict>
5 + <key>CFBundleDevelopmentRegion</key>
6 + <string>en</string>
7 + <key>CFBundleExecutable</key>
8 + <string>$(EXECUTABLE_NAME)</string>
9 + <key>CFBundleIdentifier</key>
10 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11 + <key>CFBundleName</key>
12 + <string>JomloCV Example</string>
13 + <key>CFBundlePackageType</key>
14 + <string>APPL</string>
15 + <key>CFBundleShortVersionString</key>
16 + <string>1.0</string>
17 + <key>CFBundleVersion</key>
18 + <string>1</string>
19 + <key>UIApplicationSceneManifest</key>
20 + <dict>
21 + <key>UIApplicationSupportsMultipleScenes</key>
22 + <false/>
23 + <key>UISceneConfigurations</key>
24 + <dict>
25 + <key>UIWindowSceneSessionRoleApplication</key>
26 + <array>
27 + <dict>
28 + <key>UISceneConfigurationName</key>
29 + <string>Default Configuration</string>
30 + <key>UISceneDelegateClassName</key>
31 + <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
32 + </dict>
33 + </array>
34 + </dict>
35 + </dict>
36 + <key>UILaunchScreen</key>
37 + <dict/>
38 +</dict>
39 +</plist>
Example/ExampleApp/Models/CategoryItem.swift new
+26
@@ -0,0 +1,26 @@
1 +//
2 +// CategoryItem.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +
11 +struct CategoryItem: JomloItem, @unchecked Sendable {
12 + let id: String
13 + let name: String
14 + let iconName: String
15 + let colorHex: String
16 +
17 + nonisolated var itemIdentifier: String { id }
18 +
19 + nonisolated static func == (lhs: CategoryItem, rhs: CategoryItem) -> Bool {
20 + lhs.id == rhs.id
21 + }
22 +
23 + nonisolated func hash(into hasher: inout Hasher) {
24 + hasher.combine(id)
25 + }
26 +}
Example/ExampleApp/Models/ContinueWatchingItem.swift new
+27
@@ -0,0 +1,27 @@
1 +//
2 +// ContinueWatchingItem.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +
11 +struct ContinueWatchingItem: JomloItem, @unchecked Sendable {
12 + let id: String
13 + let title: String
14 + let episodeInfo: String
15 + let progress: Float
16 + let colorHex: String
17 +
18 + nonisolated var itemIdentifier: String { id }
19 +
20 + nonisolated static func == (lhs: ContinueWatchingItem, rhs: ContinueWatchingItem) -> Bool {
21 + lhs.id == rhs.id
22 + }
23 +
24 + nonisolated func hash(into hasher: inout Hasher) {
25 + hasher.combine(id)
26 + }
27 +}
Example/ExampleApp/Models/HeroItem.swift new
+26
@@ -0,0 +1,26 @@
1 +//
2 +// HeroItem.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +
11 +struct HeroItem: JomloItem, @unchecked Sendable {
12 + let id: String
13 + let title: String
14 + let subtitle: String
15 + let gradientColors: (top: String, bottom: String)
16 +
17 + nonisolated var itemIdentifier: String { id }
18 +
19 + nonisolated static func == (lhs: HeroItem, rhs: HeroItem) -> Bool {
20 + lhs.id == rhs.id
21 + }
22 +
23 + nonisolated func hash(into hasher: inout Hasher) {
24 + hasher.combine(id)
25 + }
26 +}
Example/ExampleApp/Models/PosterItem.swift new
+26
@@ -0,0 +1,26 @@
1 +//
2 +// PosterItem.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +
11 +struct PosterItem: JomloItem, @unchecked Sendable {
12 + let id: String
13 + let title: String
14 + let year: String
15 + let colorHex: String
16 +
17 + nonisolated var itemIdentifier: String { id }
18 +
19 + nonisolated static func == (lhs: PosterItem, rhs: PosterItem) -> Bool {
20 + lhs.id == rhs.id
21 + }
22 +
23 + nonisolated func hash(into hasher: inout Hasher) {
24 + hasher.combine(id)
25 + }
26 +}
Example/ExampleApp/Sections/CategoriesGridSection.swift new
+71
@@ -0,0 +1,71 @@
1 +//
2 +// CategoriesGridSection.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +@MainActor
13 +final class CategoriesGridSection: JomloSection {
14 +
15 + let identifier = JomloSectionID("categories")
16 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
17 +
18 + private let categoryItems: [CategoryItem]
19 +
20 + init(items: [CategoryItem]) {
21 + self.categoryItems = items
22 + }
23 +
24 + func items() -> [any JomloItem] {
25 + categoryItems
26 + }
27 +
28 + func layoutSection(
29 + environment: NSCollectionLayoutEnvironment,
30 + sectionIndex: Int
31 + ) -> NSCollectionLayoutSection {
32 + let columns = environment.container.contentSize.width > 500 ? 4 : 2
33 + return JomloLayoutFactory.grid(
34 + columns: columns,
35 + itemHeight: .absolute(100),
36 + interItemSpacing: 12,
37 + headerHeight: .estimated(44)
38 + )
39 + }
40 +
41 + func cell(
42 + for item: any JomloItem,
43 + in collectionView: UICollectionView,
44 + at indexPath: IndexPath
45 + ) -> UICollectionViewCell {
46 + guard let categoryItem = item as? CategoryItem else {
47 + return UICollectionViewCell()
48 + }
49 + return collectionView.dequeueJomloCell(
50 + CategoryCell.self, for: indexPath, item: categoryItem)
51 + }
52 +
53 + func supplementaryView(
54 + ofKind kind: String,
55 + in collectionView: UICollectionView,
56 + at indexPath: IndexPath
57 + ) -> UICollectionReusableView? {
58 + guard kind == UICollectionView.elementKindSectionHeader else { return nil }
59 + guard
60 + let header = collectionView.dequeueReusableSupplementaryView(
61 + ofKind: kind,
62 + withReuseIdentifier: JomloSectionHeaderView.reuseIdentifier,
63 + for: indexPath
64 + ) as? JomloSectionHeaderView
65 + else {
66 + return nil
67 + }
68 + header.configure(title: "Categories")
69 + return header
70 + }
71 +}
Example/ExampleApp/Sections/ContinueWatchingSection.swift new
+74
@@ -0,0 +1,74 @@
1 +//
2 +// ContinueWatchingSection.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +@MainActor
13 +final class ContinueWatchingSection: JomloSection {
14 +
15 + let identifier = JomloSectionID("continue-watching")
16 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
17 +
18 + private let watchingItems: [ContinueWatchingItem]
19 +
20 + init(items: [ContinueWatchingItem]) {
21 + self.watchingItems = items
22 + }
23 +
24 + func items() -> [any JomloItem] {
25 + watchingItems
26 + }
27 +
28 + func layoutSection(
29 + environment: NSCollectionLayoutEnvironment,
30 + sectionIndex: Int
31 + ) -> NSCollectionLayoutSection {
32 + JomloLayoutFactory.horizontalShelf(
33 + itemWidth: .absolute(200),
34 + itemHeight: .absolute(170),
35 + interItemSpacing: 12,
36 + headerHeight: .estimated(44)
37 + )
38 + }
39 +
40 + func cell(
41 + for item: any JomloItem,
42 + in collectionView: UICollectionView,
43 + at indexPath: IndexPath
44 + ) -> UICollectionViewCell {
45 + guard let watchingItem = item as? ContinueWatchingItem else {
46 + return UICollectionViewCell()
47 + }
48 + return collectionView.dequeueJomloCell(
49 + ContinueWatchingCell.self, for: indexPath, item: watchingItem)
50 + }
51 +
52 + func supplementaryView(
53 + ofKind kind: String,
54 + in collectionView: UICollectionView,
55 + at indexPath: IndexPath
56 + ) -> UICollectionReusableView? {
57 + guard kind == UICollectionView.elementKindSectionHeader else { return nil }
58 + guard
59 + let header = collectionView.dequeueReusableSupplementaryView(
60 + ofKind: kind,
61 + withReuseIdentifier: JomloSectionHeaderView.reuseIdentifier,
62 + for: indexPath
63 + ) as? JomloSectionHeaderView
64 + else {
65 + return nil
66 + }
67 + header.configure(title: "Continue Watching")
68 + return header
69 + }
70 +
71 + var displayAnimation: JomloAnimator.Animation? {
72 + JomloAnimator.moveUpWithFade(offset: 20, duration: 0.4, delayFactor: 0.08)
73 + }
74 +}
Example/ExampleApp/Sections/HeroSection.swift new
+53
@@ -0,0 +1,53 @@
1 +//
2 +// HeroSection.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +@MainActor
13 +final class HeroSection: JomloSection {
14 +
15 + let identifier = JomloSectionID("hero")
16 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
17 +
18 + private let heroItems: [HeroItem]
19 +
20 + init(items: [HeroItem]) {
21 + self.heroItems = items
22 + }
23 +
24 + func items() -> [any JomloItem] {
25 + heroItems
26 + }
27 +
28 + func layoutSection(
29 + environment: NSCollectionLayoutEnvironment,
30 + sectionIndex: Int
31 + ) -> NSCollectionLayoutSection {
32 + JomloLayoutFactory.hero(
33 + height: .fractionalWidth(0.56),
34 + isPaging: true,
35 + interItemSpacing: 12
36 + )
37 + }
38 +
39 + func cell(
40 + for item: any JomloItem,
41 + in collectionView: UICollectionView,
42 + at indexPath: IndexPath
43 + ) -> UICollectionViewCell {
44 + guard let heroItem = item as? HeroItem else {
45 + return UICollectionViewCell()
46 + }
47 + return collectionView.dequeueJomloCell(HeroCell.self, for: indexPath, item: heroItem)
48 + }
49 +
50 + var displayAnimation: JomloAnimator.Animation? {
51 + JomloAnimator.scaleUp(initialScale: 0.9, duration: 0.5, delayFactor: 0.1)
52 + }
53 +}
Example/ExampleApp/Sections/ShelfSection.swift new
+95
@@ -0,0 +1,95 @@
1 +//
2 +// ShelfSection.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +@MainActor
13 +final class ShelfSection: JomloSection {
14 +
15 + let identifier: JomloSectionID
16 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
17 +
18 + private let title: String
19 + private let posterItems: [PosterItem]
20 + private let onItemSelected: (@MainActor (PosterItem) -> Void)?
21 + weak var presenter: UIViewController?
22 +
23 + init(
24 + id: String,
25 + title: String,
26 + items: [PosterItem],
27 + presenter: UIViewController? = nil,
28 + onItemSelected: (@MainActor (PosterItem) -> Void)? = nil
29 + ) {
30 + self.identifier = JomloSectionID(id)
31 + self.title = title
32 + self.posterItems = items
33 + self.presenter = presenter
34 + self.onItemSelected = onItemSelected
35 + }
36 +
37 + func items() -> [any JomloItem] {
38 + posterItems
39 + }
40 +
41 + func layoutSection(
42 + environment: NSCollectionLayoutEnvironment,
43 + sectionIndex: Int
44 + ) -> NSCollectionLayoutSection {
45 + JomloLayoutFactory.horizontalShelf(
46 + itemWidth: .absolute(140),
47 + itemHeight: .absolute(250),
48 + interItemSpacing: 12,
49 + headerHeight: .estimated(44)
50 + )
51 + }
52 +
53 + func cell(
54 + for item: any JomloItem,
55 + in collectionView: UICollectionView,
56 + at indexPath: IndexPath
57 + ) -> UICollectionViewCell {
58 + guard let posterItem = item as? PosterItem else {
59 + return UICollectionViewCell()
60 + }
61 + return collectionView.dequeueJomloCell(PosterCell.self, for: indexPath, item: posterItem)
62 + }
63 +
64 + func supplementaryView(
65 + ofKind kind: String,
66 + in collectionView: UICollectionView,
67 + at indexPath: IndexPath
68 + ) -> UICollectionReusableView? {
69 + guard kind == UICollectionView.elementKindSectionHeader else { return nil }
70 + guard
71 + let header = collectionView.dequeueReusableSupplementaryView(
72 + ofKind: kind,
73 + withReuseIdentifier: JomloSectionHeaderView.reuseIdentifier,
74 + for: indexPath
75 + ) as? JomloSectionHeaderView
76 + else {
77 + return nil
78 + }
79 + header.configure(title: title, actionTitle: "See All") { [weak self] in
80 + guard let self else { return }
81 + let seeAllVC = SeeAllViewController(title: self.title, items: self.posterItems)
82 + self.presenter?.navigationController?.pushViewController(seeAllVC, animated: true)
83 + }
84 + return header
85 + }
86 +
87 + func didSelectItem(_ item: any JomloItem, at indexPath: IndexPath) {
88 + guard let posterItem = item as? PosterItem else { return }
89 + onItemSelected?(posterItem)
90 + }
91 +
92 + var displayAnimation: JomloAnimator.Animation? {
93 + JomloAnimator.fadeIn(duration: 0.3, delayFactor: 0.05)
94 + }
95 +}
Example/ExampleApp/ViewControllers/SeeAllViewController.swift new
+150
@@ -0,0 +1,150 @@
1 +//
2 +// SeeAllViewController.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A view controller that simulates loading content from a network request,
13 +/// then displays the results in a grid layout.
14 +///
15 +/// Inspired by Viaplay's `LinkLoadingViewController` pattern — shows a loading
16 +/// indicator while fetching page data, then transitions to a content grid.
17 +@MainActor
18 +final class SeeAllViewController: JomloCollectionViewController {
19 +
20 + private let sectionTitle: String
21 + private let contentItems: [PosterItem]
22 +
23 + private lazy var loadingView: UIView = {
24 + let container = UIView()
25 + container.translatesAutoresizingMaskIntoConstraints = false
26 +
27 + let spinner = UIActivityIndicatorView(style: .large)
28 + spinner.startAnimating()
29 + spinner.translatesAutoresizingMaskIntoConstraints = false
30 +
31 + let label = UILabel()
32 + label.text = "Loading..."
33 + label.font = .preferredFont(forTextStyle: .subheadline)
34 + label.textColor = .secondaryLabel
35 + label.translatesAutoresizingMaskIntoConstraints = false
36 +
37 + container.addSubview(spinner)
38 + container.addSubview(label)
39 +
40 + NSLayoutConstraint.activate([
41 + spinner.centerXAnchor.constraint(equalTo: container.centerXAnchor),
42 + spinner.centerYAnchor.constraint(equalTo: container.centerYAnchor, constant: -16),
43 + label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
44 + label.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 12),
45 + ])
46 +
47 + return container
48 + }()
49 +
50 + init(title: String, items: [PosterItem]) {
51 + self.sectionTitle = title
52 + self.contentItems = items
53 + super.init(sections: [])
54 + }
55 +
56 + override func viewDidLoad() {
57 + super.viewDidLoad()
58 + self.title = sectionTitle
59 + view.backgroundColor = .systemBackground
60 +
61 + collectionView.registerJomloCell(PosterCell.self)
62 + collectionView.register(
63 + JomloSectionHeaderView.self,
64 + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
65 + withReuseIdentifier: JomloSectionHeaderView.reuseIdentifier
66 + )
67 +
68 + showLoading()
69 + simulateNetworkRequest()
70 + }
71 +
72 + private func showLoading() {
73 + collectionView.isHidden = true
74 + view.addSubview(loadingView)
75 + NSLayoutConstraint.activate([
76 + loadingView.topAnchor.constraint(equalTo: view.topAnchor),
77 + loadingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
78 + loadingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
79 + loadingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
80 + ])
81 + }
82 +
83 + private func simulateNetworkRequest() {
84 + // Simulate a 1.5 second network delay, then show content
85 + Task { @MainActor in
86 + try? await Task.sleep(for: .seconds(1.5))
87 + showContent()
88 + }
89 + }
90 +
91 + private func showContent() {
92 + loadingView.removeFromSuperview()
93 + collectionView.isHidden = false
94 +
95 + // Display all items in a grid layout
96 + let gridSection = SeeAllGridSection(
97 + title: sectionTitle,
98 + items: contentItems
99 + )
100 + setSections([gridSection], animated: true)
101 + }
102 +}
103 +
104 +// MARK: - Grid Section for "See All" page
105 +
106 +@MainActor
107 +private final class SeeAllGridSection: JomloSection {
108 +
109 + let identifier: JomloSectionID
110 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
111 +
112 + private let title: String
113 + private let gridItems: [PosterItem]
114 +
115 + init(title: String, items: [PosterItem]) {
116 + self.identifier = JomloSectionID("see-all-grid")
117 + self.title = title
118 + self.gridItems = items
119 + }
120 +
121 + func items() -> [any JomloItem] {
122 + gridItems
123 + }
124 +
125 + func layoutSection(
126 + environment: NSCollectionLayoutEnvironment,
127 + sectionIndex: Int
128 + ) -> NSCollectionLayoutSection {
129 + let columns = environment.container.contentSize.width > 500 ? 4 : 3
130 + return JomloLayoutFactory.grid(
131 + columns: columns,
132 + itemHeight: .absolute(220),
133 + interItemSpacing: 12,
134 + headerHeight: nil
135 + )
136 + }
137 +
138 + func cell(
139 + for item: any JomloItem,
140 + in collectionView: UICollectionView,
141 + at indexPath: IndexPath
142 + ) -> UICollectionViewCell {
143 + guard let posterItem = item as? PosterItem else { return UICollectionViewCell() }
144 + return collectionView.dequeueJomloCell(PosterCell.self, for: indexPath, item: posterItem)
145 + }
146 +
147 + var displayAnimation: JomloAnimator.Animation? {
148 + JomloAnimator.moveUpWithFade(offset: 20, duration: 0.4, delayFactor: 0.03)
149 + }
150 +}
Example/ExampleApp/ViewControllers/StreamingHomeViewController.swift new
+181
@@ -0,0 +1,181 @@
1 +//
2 +// StreamingHomeViewController.swift
3 +// JomloCollectionView Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +@MainActor
13 +final class StreamingHomeViewController: JomloCollectionViewController {
14 +
15 + init() {
16 + super.init(sections: [])
17 + }
18 +
19 + override func viewDidLoad() {
20 + super.viewDidLoad()
21 + title = "JomloCV"
22 + #if os(iOS)
23 + view.backgroundColor = .systemBackground
24 + #endif
25 +
26 + // Register cells
27 + collectionView.registerJomloCell(HeroCell.self)
28 + collectionView.registerJomloCell(PosterCell.self)
29 + collectionView.registerJomloCell(CategoryCell.self)
30 + collectionView.registerJomloCell(ContinueWatchingCell.self)
31 +
32 + // Register header
33 + collectionView.register(
34 + JomloSectionHeaderView.self,
35 + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
36 + withReuseIdentifier: JomloSectionHeaderView.reuseIdentifier
37 + )
38 +
39 + // Load content
40 + loadContent()
41 + }
42 +
43 + private func loadContent() {
44 + let sections: [any JomloSection] = [
45 + makeHeroSection(),
46 + makeContinueWatchingSection(),
47 + makeTrendingSection(),
48 + makeSeriesSection(),
49 + makeCategoriesSection(),
50 + makeMoviesSection(),
51 + ]
52 +
53 + setSections(sections, animated: true)
54 + }
55 +
56 + // MARK: - Section Factories
57 +
58 + private func makeHeroSection() -> HeroSection {
59 + HeroSection(items: [
60 + HeroItem(
61 + id: "hero-1",
62 + title: "The Last Kingdom",
63 + subtitle: "Season 5 Now Streaming",
64 + gradientColors: (top: "#1a237e", bottom: "#4a148c")
65 + ),
66 + HeroItem(
67 + id: "hero-2",
68 + title: "Formula 1: Drive to Survive",
69 + subtitle: "New Season Available",
70 + gradientColors: (top: "#b71c1c", bottom: "#e65100")
71 + ),
72 + HeroItem(
73 + id: "hero-3",
74 + title: "Premier League Live",
75 + subtitle: "Every Match, Every Week",
76 + gradientColors: (top: "#1b5e20", bottom: "#004d40")
77 + ),
78 + ])
79 + }
80 +
81 + private func makeContinueWatchingSection() -> ContinueWatchingSection {
82 + ContinueWatchingSection(items: [
83 + ContinueWatchingItem(
84 + id: "cw-1", title: "Breaking Bad", episodeInfo: "S3 E7 \u{00B7} One Minute",
85 + progress: 0.65, colorHex: "#2e7d32"),
86 + ContinueWatchingItem(
87 + id: "cw-2", title: "The Bear", episodeInfo: "S2 E3 \u{00B7} Sundae", progress: 0.3,
88 + colorHex: "#f57f17"),
89 + ContinueWatchingItem(
90 + id: "cw-3", title: "Succession", episodeInfo: "S4 E1 \u{00B7} The Munsters",
91 + progress: 0.82, colorHex: "#1565c0"),
92 + ContinueWatchingItem(
93 + id: "cw-4", title: "Slow Horses", episodeInfo: "S3 E4 \u{00B7} Uninvited",
94 + progress: 0.45, colorHex: "#6a1b9a"),
95 + ])
96 + }
97 +
98 + private func makeTrendingSection() -> ShelfSection {
99 + ShelfSection(
100 + id: "trending",
101 + title: "Trending Now",
102 + items: [
103 + PosterItem(id: "t-1", title: "Oppenheimer", year: "2023", colorHex: "#ff6f00"),
104 + PosterItem(
105 + id: "t-2", title: "Killers of the Flower Moon", year: "2023",
106 + colorHex: "#4e342e"),
107 + PosterItem(id: "t-3", title: "Past Lives", year: "2023", colorHex: "#00838f"),
108 + PosterItem(
109 + id: "t-4", title: "Anatomy of a Fall", year: "2023", colorHex: "#283593"),
110 + PosterItem(id: "t-5", title: "The Holdovers", year: "2023", colorHex: "#2e7d32"),
111 + PosterItem(id: "t-6", title: "Poor Things", year: "2023", colorHex: "#ad1457"),
112 + ],
113 + presenter: self
114 + )
115 + }
116 +
117 + private func makeSeriesSection() -> ShelfSection {
118 + ShelfSection(
119 + id: "series",
120 + title: "Popular Series",
121 + items: [
122 + PosterItem(id: "s-1", title: "The Bear", year: "2022\u{2013}", colorHex: "#e65100"),
123 + PosterItem(
124 + id: "s-2", title: "Slow Horses", year: "2022\u{2013}", colorHex: "#1a237e"),
125 + PosterItem(
126 + id: "s-3", title: "Severance", year: "2022\u{2013}", colorHex: "#004d40"),
127 + PosterItem(id: "s-4", title: "Shogun", year: "2024", colorHex: "#b71c1c"),
128 + PosterItem(id: "s-5", title: "True Detective", year: "2024", colorHex: "#311b92"),
129 + PosterItem(id: "s-6", title: "Fallout", year: "2024", colorHex: "#33691e"),
130 + ],
131 + presenter: self
132 + )
133 + }
134 +
135 + private func makeCategoriesSection() -> CategoriesGridSection {
136 + CategoriesGridSection(items: [
137 + CategoryItem(id: "cat-1", name: "Movies", iconName: "\u{1F3AC}", colorHex: "#c62828"),
138 + CategoryItem(id: "cat-2", name: "Series", iconName: "\u{1F4FA}", colorHex: "#ad1457"),
139 + CategoryItem(id: "cat-3", name: "Sports", iconName: "\u{26BD}", colorHex: "#1565c0"),
140 + CategoryItem(id: "cat-4", name: "Kids", iconName: "\u{1F9F8}", colorHex: "#2e7d32"),
141 + CategoryItem(
142 + id: "cat-5", name: "Documentaries", iconName: "\u{1F30D}", colorHex: "#e65100"),
143 + CategoryItem(id: "cat-6", name: "Live TV", iconName: "\u{1F4E1}", colorHex: "#4527a0"),
144 + ])
145 + }
146 +
147 + private func makeMoviesSection() -> ShelfSection {
148 + ShelfSection(
149 + id: "movies",
150 + title: "New Movies",
151 + items: [
152 + PosterItem(id: "m-1", title: "Dune: Part Two", year: "2024", colorHex: "#ff8f00"),
153 + PosterItem(id: "m-2", title: "Civil War", year: "2024", colorHex: "#455a64"),
154 + PosterItem(id: "m-3", title: "Challengers", year: "2024", colorHex: "#00695c"),
155 + PosterItem(
156 + id: "m-4", title: "The Zone of Interest", year: "2023", colorHex: "#37474f"),
157 + PosterItem(
158 + id: "m-5", title: "All of Us Strangers", year: "2023", colorHex: "#1a237e"),
159 + ],
160 + presenter: self
161 + )
162 + }
163 +}
164 +
165 +// MARK: - UIColor Hex Extension
166 +
167 +extension UIColor {
168 + convenience init?(hex: String) {
169 + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
170 + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
171 +
172 + var rgb: UInt64 = 0
173 + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
174 +
175 + let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
176 + let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
177 + let b = CGFloat(rgb & 0x0000FF) / 255.0
178 +
179 + self.init(red: r, green: g, blue: b, alpha: 1.0)
180 + }
181 +}
Example/ExampleApp_tvOS/AppDelegate.swift new
+27
@@ -0,0 +1,27 @@
1 +//
2 +// AppDelegate.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +@main
12 +class AppDelegate: UIResponder, UIApplicationDelegate {
13 +
14 + var window: UIWindow?
15 +
16 + func application(
17 + _ application: UIApplication,
18 + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
19 + ) -> Bool {
20 + let window = UIWindow(frame: UIScreen.main.bounds)
21 + let tabBarController = TVTabBarController()
22 + window.rootViewController = tabBarController
23 + window.makeKeyAndVisible()
24 + self.window = window
25 + return true
26 + }
27 +}
Example/ExampleApp_tvOS/Cells/TVCategoryCell.swift new
+96
@@ -0,0 +1,96 @@
1 +//
2 +// TVCategoryCell.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A large category tile for TV navigation grids.
13 +/// Features prominent icon + label with focus scaling and color highlight.
14 +final class TVCategoryCell: UICollectionViewCell, JomloSelfConfiguringCell {
15 +
16 + private let iconLabel: UILabel = {
17 + let label = UILabel()
18 + label.font = .systemFont(ofSize: 52)
19 + label.textAlignment = .center
20 + return label
21 + }()
22 +
23 + private let nameLabel: UILabel = {
24 + let label = UILabel()
25 + label.font = .systemFont(ofSize: 26, weight: .semibold)
26 + label.textColor = .white
27 + label.textAlignment = .center
28 + return label
29 + }()
30 +
31 + override init(frame: CGRect) {
32 + super.init(frame: frame)
33 + setupViews()
34 + }
35 +
36 + required init?(coder: NSCoder) {
37 + fatalError("init(coder:) not supported")
38 + }
39 +
40 + func configure(with item: TVCategoryItem) {
41 + iconLabel.text = item.icon
42 + nameLabel.text = item.name
43 + contentView.backgroundColor = UIColor(hex: item.colorHex) ?? .darkGray
44 + }
45 +
46 + // MARK: - Focus Engine
47 +
48 + override var canBecomeFocused: Bool { true }
49 +
50 + override func didUpdateFocus(
51 + in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator
52 + ) {
53 + coordinator.addCoordinatedAnimations(
54 + {
55 + if self.isFocused {
56 + self.transform = CGAffineTransform(scaleX: 1.06, y: 1.06)
57 + self.contentView.layer.borderColor = UIColor.white.cgColor
58 + self.contentView.layer.borderWidth = 4
59 + self.layer.shadowColor = UIColor.black.cgColor
60 + self.layer.shadowOpacity = 0.6
61 + self.layer.shadowRadius = 12
62 + self.layer.shadowOffset = CGSize(width: 0, height: 10)
63 + } else {
64 + self.transform = .identity
65 + self.contentView.layer.borderWidth = 0
66 + self.layer.shadowOpacity = 0
67 + }
68 + }, completion: nil)
69 + }
70 +
71 + private func setupViews() {
72 + contentView.layer.cornerRadius = 18
73 + contentView.clipsToBounds = true
74 + clipsToBounds = false
75 +
76 + let stack = UIStackView(arrangedSubviews: [iconLabel, nameLabel])
77 + stack.axis = .vertical
78 + stack.spacing = 10
79 + stack.alignment = .center
80 + stack.translatesAutoresizingMaskIntoConstraints = false
81 +
82 + contentView.addSubview(stack)
83 + NSLayoutConstraint.activate([
84 + stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
85 + stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
86 + ])
87 +
88 + isAccessibilityElement = true
89 + accessibilityTraits = .button
90 + }
91 +
92 + override var accessibilityLabel: String? {
93 + get { nameLabel.text }
94 + set {}
95 + }
96 +}
Example/ExampleApp_tvOS/Cells/TVHeroCell.swift new
+134
@@ -0,0 +1,134 @@
1 +//
2 +// TVHeroCell.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A large hero cell for the top-shelf-style featured content area.
13 +/// Designed for 1920x1080 TV screens with generous padding and large typography.
14 +final class TVHeroCell: UICollectionViewCell, JomloSelfConfiguringCell {
15 +
16 + private let gradientLayer = CAGradientLayer()
17 +
18 + private let titleLabel: UILabel = {
19 + let label = UILabel()
20 + label.font = .systemFont(ofSize: 56, weight: .bold)
21 + label.textColor = .white
22 + label.numberOfLines = 2
23 + return label
24 + }()
25 +
26 + private let taglineLabel: UILabel = {
27 + let label = UILabel()
28 + label.font = .systemFont(ofSize: 28, weight: .medium)
29 + label.textColor = UIColor.white.withAlphaComponent(0.85)
30 + label.numberOfLines = 1
31 + return label
32 + }()
33 +
34 + private let metadataLabel: UILabel = {
35 + let label = UILabel()
36 + label.font = .systemFont(ofSize: 22, weight: .regular)
37 + label.textColor = UIColor.white.withAlphaComponent(0.6)
38 + return label
39 + }()
40 +
41 + private let focusOverlay: UIView = {
42 + let view = UIView()
43 + view.backgroundColor = UIColor.white.withAlphaComponent(0.08)
44 + view.layer.cornerRadius = 24
45 + view.alpha = 0
46 + return view
47 + }()
48 +
49 + override init(frame: CGRect) {
50 + super.init(frame: frame)
51 + setupViews()
52 + }
53 +
54 + required init?(coder: NSCoder) {
55 + fatalError("init(coder:) not supported")
56 + }
57 +
58 + override func layoutSubviews() {
59 + super.layoutSubviews()
60 + gradientLayer.frame = contentView.bounds
61 + }
62 +
63 + func configure(with item: TVHeroItem) {
64 + titleLabel.text = item.title
65 + taglineLabel.text = item.tagline
66 + metadataLabel.text = item.metadata
67 +
68 + let topColor = UIColor(hex: item.gradientTop) ?? .darkGray
69 + let bottomColor = UIColor(hex: item.gradientBottom) ?? .black
70 + gradientLayer.colors = [topColor.cgColor, bottomColor.cgColor]
71 + }
72 +
73 + // MARK: - Focus Engine
74 +
75 + override var canBecomeFocused: Bool { true }
76 +
77 + override func didUpdateFocus(
78 + in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator
79 + ) {
80 + coordinator.addCoordinatedAnimations(
81 + {
82 + if self.isFocused {
83 + self.transform = CGAffineTransform(scaleX: 1.03, y: 1.03)
84 + self.focusOverlay.alpha = 1
85 + self.layer.shadowColor = UIColor.white.cgColor
86 + self.layer.shadowOpacity = 0.3
87 + self.layer.shadowRadius = 20
88 + self.layer.shadowOffset = CGSize(width: 0, height: 10)
89 + } else {
90 + self.transform = .identity
91 + self.focusOverlay.alpha = 0
92 + self.layer.shadowOpacity = 0
93 + }
94 + }, completion: nil)
95 + }
96 +
97 + private func setupViews() {
98 + contentView.layer.cornerRadius = 24
99 + contentView.clipsToBounds = true
100 + clipsToBounds = false
101 + contentView.layer.insertSublayer(gradientLayer, at: 0)
102 + gradientLayer.startPoint = CGPoint(x: 0, y: 0)
103 + gradientLayer.endPoint = CGPoint(x: 1, y: 1)
104 +
105 + focusOverlay.translatesAutoresizingMaskIntoConstraints = false
106 + contentView.addSubview(focusOverlay)
107 +
108 + let textStack = UIStackView(arrangedSubviews: [titleLabel, taglineLabel, metadataLabel])
109 + textStack.axis = .vertical
110 + textStack.spacing = 12
111 + textStack.alignment = .leading
112 + textStack.translatesAutoresizingMaskIntoConstraints = false
113 + contentView.addSubview(textStack)
114 +
115 + NSLayoutConstraint.activate([
116 + focusOverlay.topAnchor.constraint(equalTo: contentView.topAnchor),
117 + focusOverlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
118 + focusOverlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
119 + focusOverlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
120 +
121 + textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 60),
122 + textStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -60),
123 + textStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -50),
124 + ])
125 +
126 + isAccessibilityElement = true
127 + accessibilityTraits = .button
128 + }
129 +
130 + override var accessibilityLabel: String? {
131 + get { "\(titleLabel.text ?? ""). \(taglineLabel.text ?? "")" }
132 + set {}
133 + }
134 +}
Example/ExampleApp_tvOS/Cells/TVPosterCell.swift new
+112
@@ -0,0 +1,112 @@
1 +//
2 +// TVPosterCell.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A poster cell optimized for TV — large artwork area with focus-driven scaling
13 +/// and shadow. Uses the tvOS focus engine for smooth parallax-like interactions.
14 +final class TVPosterCell: UICollectionViewCell, JomloSelfConfiguringCell {
15 +
16 + private let posterView: UIView = {
17 + let view = UIView()
18 + view.layer.cornerRadius = 12
19 + view.clipsToBounds = true
20 + return view
21 + }()
22 +
23 + private let titleLabel: UILabel = {
24 + let label = UILabel()
25 + label.font = .systemFont(ofSize: 24, weight: .medium)
26 + label.textColor = .white
27 + label.textAlignment = .center
28 + label.numberOfLines = 2
29 + return label
30 + }()
31 +
32 + private let subtitleLabel: UILabel = {
33 + let label = UILabel()
34 + label.font = .systemFont(ofSize: 18, weight: .regular)
35 + label.textColor = UIColor.white.withAlphaComponent(0.7)
36 + label.textAlignment = .center
37 + return label
38 + }()
39 +
40 + override init(frame: CGRect) {
41 + super.init(frame: frame)
42 + setupViews()
43 + }
44 +
45 + required init?(coder: NSCoder) {
46 + fatalError("init(coder:) not supported")
47 + }
48 +
49 + func configure(with item: TVContentItem) {
50 + titleLabel.text = item.title
51 + subtitleLabel.text = item.subtitle
52 + posterView.backgroundColor = UIColor(hex: item.colorHex) ?? .darkGray
53 + }
54 +
55 + // MARK: - Focus Engine
56 +
57 + override var canBecomeFocused: Bool { true }
58 +
59 + override func didUpdateFocus(
60 + in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator
61 + ) {
62 + coordinator.addCoordinatedAnimations(
63 + {
64 + if self.isFocused {
65 + self.transform = CGAffineTransform(scaleX: 1.08, y: 1.08)
66 + self.layer.shadowColor = UIColor.black.cgColor
67 + self.layer.shadowOpacity = 0.5
68 + self.layer.shadowRadius = 15
69 + self.layer.shadowOffset = CGSize(width: 0, height: 15)
70 + self.titleLabel.alpha = 1
71 + self.subtitleLabel.alpha = 1
72 + } else {
73 + self.transform = .identity
74 + self.layer.shadowOpacity = 0
75 + self.titleLabel.alpha = 0.8
76 + self.subtitleLabel.alpha = 0.5
77 + }
78 + }, completion: nil)
79 + }
80 +
81 + private func setupViews() {
82 + clipsToBounds = false
83 + contentView.clipsToBounds = false
84 +
85 + let stack = UIStackView(arrangedSubviews: [posterView, titleLabel, subtitleLabel])
86 + stack.axis = .vertical
87 + stack.spacing = 12
88 + stack.alignment = .center
89 + stack.translatesAutoresizingMaskIntoConstraints = false
90 +
91 + contentView.addSubview(stack)
92 + NSLayoutConstraint.activate([
93 + stack.topAnchor.constraint(equalTo: contentView.topAnchor),
94 + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
95 + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
96 + stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor),
97 + posterView.widthAnchor.constraint(equalTo: contentView.widthAnchor),
98 + posterView.heightAnchor.constraint(equalTo: posterView.widthAnchor, multiplier: 1.5),
99 + ])
100 +
101 + titleLabel.alpha = 0.8
102 + subtitleLabel.alpha = 0.5
103 +
104 + isAccessibilityElement = true
105 + accessibilityTraits = .button
106 + }
107 +
108 + override var accessibilityLabel: String? {
109 + get { "\(titleLabel.text ?? ""). \(subtitleLabel.text ?? "")" }
110 + set {}
111 + }
112 +}
Example/ExampleApp_tvOS/Cells/TVResumeCell.swift new
+124
@@ -0,0 +1,124 @@
1 +//
2 +// TVResumeCell.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A "Continue Watching" cell for TV with large thumbnail, progress bar,
13 +/// and remaining-time badge. Focus causes the cell to lift with shadow.
14 +final class TVResumeCell: UICollectionViewCell, JomloSelfConfiguringCell {
15 +
16 + private let thumbnailView: UIView = {
17 + let view = UIView()
18 + view.layer.cornerRadius = 14
19 + view.clipsToBounds = true
20 + return view
21 + }()
22 +
23 + private let titleLabel: UILabel = {
24 + let label = UILabel()
25 + label.font = .systemFont(ofSize: 24, weight: .semibold)
26 + label.textColor = .white
27 + return label
28 + }()
29 +
30 + private let episodeLabel: UILabel = {
31 + let label = UILabel()
32 + label.font = .systemFont(ofSize: 20, weight: .regular)
33 + label.textColor = UIColor.white.withAlphaComponent(0.7)
34 + return label
35 + }()
36 +
37 + private let remainingLabel: UILabel = {
38 + let label = UILabel()
39 + label.font = .systemFont(ofSize: 18, weight: .medium)
40 + label.textColor = UIColor.white.withAlphaComponent(0.5)
41 + return label
42 + }()
43 +
44 + private let progressView: UIProgressView = {
45 + let pv = UIProgressView(progressViewStyle: .default)
46 + pv.trackTintColor = UIColor.white.withAlphaComponent(0.2)
47 + pv.progressTintColor = .systemRed
48 + pv.layer.cornerRadius = 3
49 + pv.clipsToBounds = true
50 + return pv
51 + }()
52 +
53 + override init(frame: CGRect) {
54 + super.init(frame: frame)
55 + setupViews()
56 + }
57 +
58 + required init?(coder: NSCoder) {
59 + fatalError("init(coder:) not supported")
60 + }
61 +
62 + func configure(with item: TVResumeItem) {
63 + titleLabel.text = item.title
64 + episodeLabel.text = item.episodeInfo
65 + remainingLabel.text = item.remainingTime
66 + progressView.progress = item.progress
67 + thumbnailView.backgroundColor = UIColor(hex: item.colorHex) ?? .darkGray
68 + }
69 +
70 + // MARK: - Focus Engine
71 +
72 + override var canBecomeFocused: Bool { true }
73 +
74 + override func didUpdateFocus(
75 + in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator
76 + ) {
77 + coordinator.addCoordinatedAnimations(
78 + {
79 + if self.isFocused {
80 + self.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
81 + self.layer.shadowColor = UIColor.black.cgColor
82 + self.layer.shadowOpacity = 0.5
83 + self.layer.shadowRadius = 15
84 + self.layer.shadowOffset = CGSize(width: 0, height: 12)
85 + } else {
86 + self.transform = .identity
87 + self.layer.shadowOpacity = 0
88 + }
89 + }, completion: nil)
90 + }
91 +
92 + private func setupViews() {
93 + clipsToBounds = false
94 + contentView.clipsToBounds = false
95 +
96 + let textStack = UIStackView(arrangedSubviews: [titleLabel, episodeLabel, remainingLabel])
97 + textStack.axis = .vertical
98 + textStack.spacing = 6
99 +
100 + let mainStack = UIStackView(arrangedSubviews: [thumbnailView, progressView, textStack])
101 + mainStack.axis = .vertical
102 + mainStack.spacing = 12
103 + mainStack.translatesAutoresizingMaskIntoConstraints = false
104 +
105 + contentView.addSubview(mainStack)
106 + NSLayoutConstraint.activate([
107 + mainStack.topAnchor.constraint(equalTo: contentView.topAnchor),
108 + mainStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
109 + mainStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
110 + mainStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor),
111 + thumbnailView.heightAnchor.constraint(
112 + equalTo: thumbnailView.widthAnchor, multiplier: 0.56),
113 + progressView.heightAnchor.constraint(equalToConstant: 6),
114 + ])
115 +
116 + isAccessibilityElement = true
117 + accessibilityTraits = .button
118 + }
119 +
120 + override var accessibilityLabel: String? {
121 + get { "\(titleLabel.text ?? ""). \(episodeLabel.text ?? ""). \(remainingLabel.text ?? "")" }
122 + set {}
123 + }
124 +}
Example/ExampleApp_tvOS/Cells/TVSeeAllCell.swift new
+117
@@ -0,0 +1,117 @@
1 +//
2 +// TVSeeAllCell.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// The "See All" last-item card for tvOS shelves.
13 +///
14 +/// Matches the Viaplay design: dark semi-transparent background,
15 +/// a prompt question, and a prominent pink pill button — all
16 +/// the same height as a poster card so it sits flush in the shelf.
17 +///
18 +/// Focus behaviour mirrors `TVPosterCell`: scales up with a shadow lift.
19 +final class TVSeeAllCell: UICollectionViewCell, JomloSelfConfiguringCell {
20 +
21 + // MARK: - Views
22 +
23 + private let promptLabel: UILabel = {
24 + let label = UILabel()
25 + label.text = "Do you want to see more similar titles?"
26 + label.font = .systemFont(ofSize: 26, weight: .medium)
27 + label.textColor = .white
28 + label.textAlignment = .center
29 + label.numberOfLines = 0
30 + return label
31 + }()
32 +
33 + private let pillButton: UIButton = {
34 + let button = UIButton(type: .custom)
35 + button.setTitle("See All", for: .normal)
36 + button.titleLabel?.font = .systemFont(ofSize: 24, weight: .bold)
37 + button.setTitleColor(.white, for: .normal)
38 + // Viaplay pink/magenta — #E8005A
39 + button.backgroundColor = UIColor(red: 0.91, green: 0.0, blue: 0.35, alpha: 1.0)
40 + button.layer.cornerRadius = 28
41 + button.contentEdgeInsets = UIEdgeInsets(top: 14, left: 36, bottom: 14, right: 36)
42 + // Disable interaction — the cell itself handles selection
43 + button.isUserInteractionEnabled = false
44 + return button
45 + }()
46 +
47 + // MARK: - Init
48 +
49 + override init(frame: CGRect) {
50 + super.init(frame: frame)
51 + setupViews()
52 + }
53 +
54 + required init?(coder: NSCoder) {
55 + fatalError("init(coder:) not supported")
56 + }
57 +
58 + // MARK: - JomloSelfConfiguringCell
59 +
60 + func configure(with item: TVSeeAllItem) {
61 + // The layout is static — nothing to configure from the model
62 + }
63 +
64 + // MARK: - Focus Engine
65 +
66 + override var canBecomeFocused: Bool { true }
67 +
68 + override func didUpdateFocus(
69 + in context: UIFocusUpdateContext,
70 + with coordinator: UIFocusAnimationCoordinator
71 + ) {
72 + coordinator.addCoordinatedAnimations(
73 + {
74 + if self.isFocused {
75 + self.transform = CGAffineTransform(scaleX: 1.08, y: 1.08)
76 + self.layer.shadowColor = UIColor.black.cgColor
77 + self.layer.shadowOpacity = 0.6
78 + self.layer.shadowRadius = 20
79 + self.layer.shadowOffset = CGSize(width: 0, height: 16)
80 + // Brighten the pill on focus
81 + self.pillButton.transform = CGAffineTransform(scaleX: 1.04, y: 1.04)
82 + } else {
83 + self.transform = .identity
84 + self.layer.shadowOpacity = 0
85 + self.pillButton.transform = .identity
86 + }
87 + }, completion: nil)
88 + }
89 +
90 + // MARK: - Layout
91 +
92 + private func setupViews() {
93 + // Dark frosted-glass look — matches the screenshot
94 + contentView.backgroundColor = UIColor(white: 0.12, alpha: 1.0)
95 + contentView.layer.cornerRadius = 14
96 + contentView.clipsToBounds = true
97 + clipsToBounds = false
98 +
99 + let stack = UIStackView(arrangedSubviews: [promptLabel, pillButton])
100 + stack.axis = .vertical
101 + stack.spacing = 32
102 + stack.alignment = .center
103 + stack.translatesAutoresizingMaskIntoConstraints = false
104 +
105 + contentView.addSubview(stack)
106 + NSLayoutConstraint.activate([
107 + stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
108 + stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
109 + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
110 + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
111 + ])
112 +
113 + isAccessibilityElement = true
114 + accessibilityTraits = .button
115 + accessibilityLabel = "See all. Do you want to see more similar titles?"
116 + }
117 +}
Example/ExampleApp_tvOS/Info.plist new
+22
@@ -0,0 +1,22 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<plist version="1.0">
4 +<dict>
5 + <key>CFBundleDevelopmentRegion</key>
6 + <string>en</string>
7 + <key>CFBundleExecutable</key>
8 + <string>$(EXECUTABLE_NAME)</string>
9 + <key>CFBundleIdentifier</key>
10 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11 + <key>CFBundleName</key>
12 + <string>JomloCV tvOS</string>
13 + <key>CFBundlePackageType</key>
14 + <string>APPL</string>
15 + <key>CFBundleShortVersionString</key>
16 + <string>1.0</string>
17 + <key>CFBundleVersion</key>
18 + <string>1</string>
19 + <key>UILaunchScreen</key>
20 + <dict/>
21 +</dict>
22 +</plist>
Example/ExampleApp_tvOS/Models/TVContentItem.swift new
+103
@@ -0,0 +1,103 @@
1 +//
2 +// TVContentItem.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +
11 +/// A content item representing a movie or show on the big screen.
12 +struct TVContentItem: JomloItem, @unchecked Sendable {
13 + let id: String
14 + let title: String
15 + let subtitle: String
16 + let colorHex: String
17 + let isFeatured: Bool
18 +
19 + nonisolated var itemIdentifier: String { id }
20 +
21 + nonisolated static func == (lhs: TVContentItem, rhs: TVContentItem) -> Bool {
22 + lhs.id == rhs.id
23 + }
24 +
25 + nonisolated func hash(into hasher: inout Hasher) {
26 + hasher.combine(id)
27 + }
28 +}
29 +
30 +/// A hero/featured item with richer metadata for the top-shelf style display.
31 +struct TVHeroItem: JomloItem, @unchecked Sendable {
32 + let id: String
33 + let title: String
34 + let tagline: String
35 + let metadata: String
36 + let gradientTop: String
37 + let gradientBottom: String
38 +
39 + nonisolated var itemIdentifier: String { id }
40 +
41 + nonisolated static func == (lhs: TVHeroItem, rhs: TVHeroItem) -> Bool {
42 + lhs.id == rhs.id
43 + }
44 +
45 + nonisolated func hash(into hasher: inout Hasher) {
46 + hasher.combine(id)
47 + }
48 +}
49 +
50 +/// A category/channel item for the grid navigation.
51 +struct TVCategoryItem: JomloItem, @unchecked Sendable {
52 + let id: String
53 + let name: String
54 + let icon: String
55 + let colorHex: String
56 +
57 + nonisolated var itemIdentifier: String { id }
58 +
59 + nonisolated static func == (lhs: TVCategoryItem, rhs: TVCategoryItem) -> Bool {
60 + lhs.id == rhs.id
61 + }
62 +
63 + nonisolated func hash(into hasher: inout Hasher) {
64 + hasher.combine(id)
65 + }
66 +}
67 +
68 +/// A sentinel item that renders as the "See All" card at the end of a shelf.
69 +/// Tapping it (or pressing select on the Siri Remote) pushes the full grid page.
70 +struct TVSeeAllItem: JomloItem, @unchecked Sendable {
71 + let id: String
72 + let sectionTitle: String
73 +
74 + nonisolated var itemIdentifier: String { id }
75 +
76 + nonisolated static func == (lhs: TVSeeAllItem, rhs: TVSeeAllItem) -> Bool {
77 + lhs.id == rhs.id
78 + }
79 +
80 + nonisolated func hash(into hasher: inout Hasher) {
81 + hasher.combine(id)
82 + }
83 +}
84 +
85 +/// A "continue watching" item with progress state.
86 +struct TVResumeItem: JomloItem, @unchecked Sendable {
87 + let id: String
88 + let title: String
89 + let episodeInfo: String
90 + let progress: Float
91 + let colorHex: String
92 + let remainingTime: String
93 +
94 + nonisolated var itemIdentifier: String { id }
95 +
96 + nonisolated static func == (lhs: TVResumeItem, rhs: TVResumeItem) -> Bool {
97 + lhs.id == rhs.id
98 + }
99 +
100 + nonisolated func hash(into hasher: inout Hasher) {
101 + hasher.combine(id)
102 + }
103 +}
Example/ExampleApp_tvOS/Sections/TVCategorySection.swift new
+90
@@ -0,0 +1,90 @@
1 +//
2 +// TVCategorySection.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A horizontally scrolling row of large category tiles for TV navigation.
13 +@MainActor
14 +final class TVCategorySection: JomloSection {
15 +
16 + let identifier = JomloSectionID("tv-categories")
17 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
18 +
19 + private let categoryItems: [TVCategoryItem]
20 +
21 + init(items: [TVCategoryItem]) {
22 + self.categoryItems = items
23 + }
24 +
25 + func items() -> [any JomloItem] {
26 + categoryItems
27 + }
28 +
29 + func layoutSection(
30 + environment: NSCollectionLayoutEnvironment,
31 + sectionIndex: Int
32 + ) -> NSCollectionLayoutSection {
33 + let itemSize = NSCollectionLayoutSize(
34 + widthDimension: .fractionalWidth(1.0),
35 + heightDimension: .fractionalHeight(1.0)
36 + )
37 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
38 +
39 + let groupSize = NSCollectionLayoutSize(
40 + widthDimension: .absolute(280),
41 + heightDimension: .absolute(180)
42 + )
43 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
44 +
45 + let section = NSCollectionLayoutSection(group: group)
46 + section.orthogonalScrollingBehavior = .continuous
47 + section.interGroupSpacing = 35
48 + section.contentInsets = NSDirectionalEdgeInsets(
49 + top: 10, leading: 80, bottom: 50, trailing: 80)
50 +
51 + let headerSize = NSCollectionLayoutSize(
52 + widthDimension: .fractionalWidth(1.0),
53 + heightDimension: .absolute(60)
54 + )
55 + let header = NSCollectionLayoutBoundarySupplementaryItem(
56 + layoutSize: headerSize,
57 + elementKind: UICollectionView.elementKindSectionHeader,
58 + alignment: .top
59 + )
60 + section.boundarySupplementaryItems = [header]
61 +
62 + return section
63 + }
64 +
65 + func cell(
66 + for item: any JomloItem,
67 + in collectionView: UICollectionView,
68 + at indexPath: IndexPath
69 + ) -> UICollectionViewCell {
70 + guard let catItem = item as? TVCategoryItem else { return UICollectionViewCell() }
71 + return collectionView.dequeueJomloCell(TVCategoryCell.self, for: indexPath, item: catItem)
72 + }
73 +
74 + func supplementaryView(
75 + ofKind kind: String,
76 + in collectionView: UICollectionView,
77 + at indexPath: IndexPath
78 + ) -> UICollectionReusableView? {
79 + guard kind == UICollectionView.elementKindSectionHeader else { return nil }
80 + guard
81 + let header = collectionView.dequeueReusableSupplementaryView(
82 + ofKind: kind,
83 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier,
84 + for: indexPath
85 + ) as? TVSectionHeaderView
86 + else { return nil }
87 + header.configure(title: "Browse")
88 + return header
89 + }
90 +}
Example/ExampleApp_tvOS/Sections/TVHeroSection.swift new
+63
@@ -0,0 +1,63 @@
1 +//
2 +// TVHeroSection.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A full-width hero section for featured content at the top of the TV screen.
13 +/// Uses group paging for a cinematic browsing feel with the Siri Remote.
14 +@MainActor
15 +final class TVHeroSection: JomloSection {
16 +
17 + let identifier = JomloSectionID("tv-hero")
18 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
19 +
20 + private let heroItems: [TVHeroItem]
21 +
22 + init(items: [TVHeroItem]) {
23 + self.heroItems = items
24 + }
25 +
26 + func items() -> [any JomloItem] {
27 + heroItems
28 + }
29 +
30 + func layoutSection(
31 + environment: NSCollectionLayoutEnvironment,
32 + sectionIndex: Int
33 + ) -> NSCollectionLayoutSection {
34 + // Full-width hero sized for 1080p TV (16:9 ratio, padded)
35 + let itemSize = NSCollectionLayoutSize(
36 + widthDimension: .fractionalWidth(1.0),
37 + heightDimension: .fractionalHeight(1.0)
38 + )
39 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
40 +
41 + let groupSize = NSCollectionLayoutSize(
42 + widthDimension: .fractionalWidth(0.85),
43 + heightDimension: .absolute(500)
44 + )
45 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
46 +
47 + let section = NSCollectionLayoutSection(group: group)
48 + section.orthogonalScrollingBehavior = .groupPagingCentered
49 + section.interGroupSpacing = 40
50 + section.contentInsets = NSDirectionalEdgeInsets(
51 + top: 20, leading: 0, bottom: 50, trailing: 0)
52 + return section
53 + }
54 +
55 + func cell(
56 + for item: any JomloItem,
57 + in collectionView: UICollectionView,
58 + at indexPath: IndexPath
59 + ) -> UICollectionViewCell {
60 + guard let heroItem = item as? TVHeroItem else { return UICollectionViewCell() }
61 + return collectionView.dequeueJomloCell(TVHeroCell.self, for: indexPath, item: heroItem)
62 + }
63 +}
Example/ExampleApp_tvOS/Sections/TVResumeSection.swift new
+91
@@ -0,0 +1,91 @@
1 +//
2 +// TVResumeSection.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A "Continue Watching" shelf with wide landscape cards showing progress.
13 +@MainActor
14 +final class TVResumeSection: JomloSection {
15 +
16 + let identifier = JomloSectionID("tv-resume")
17 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
18 +
19 + private let resumeItems: [TVResumeItem]
20 +
21 + init(items: [TVResumeItem]) {
22 + self.resumeItems = items
23 + }
24 +
25 + func items() -> [any JomloItem] {
26 + resumeItems
27 + }
28 +
29 + func layoutSection(
30 + environment: NSCollectionLayoutEnvironment,
31 + sectionIndex: Int
32 + ) -> NSCollectionLayoutSection {
33 + // Wide landscape cards — 16:9 thumbnails
34 + let itemSize = NSCollectionLayoutSize(
35 + widthDimension: .fractionalWidth(1.0),
36 + heightDimension: .fractionalHeight(1.0)
37 + )
38 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
39 +
40 + let groupSize = NSCollectionLayoutSize(
41 + widthDimension: .absolute(380),
42 + heightDimension: .absolute(320)
43 + )
44 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
45 +
46 + let section = NSCollectionLayoutSection(group: group)
47 + section.orthogonalScrollingBehavior = .continuous
48 + section.interGroupSpacing = 40
49 + section.contentInsets = NSDirectionalEdgeInsets(
50 + top: 10, leading: 80, bottom: 50, trailing: 80)
51 +
52 + let headerSize = NSCollectionLayoutSize(
53 + widthDimension: .fractionalWidth(1.0),
54 + heightDimension: .absolute(60)
55 + )
56 + let header = NSCollectionLayoutBoundarySupplementaryItem(
57 + layoutSize: headerSize,
58 + elementKind: UICollectionView.elementKindSectionHeader,
59 + alignment: .top
60 + )
61 + section.boundarySupplementaryItems = [header]
62 +
63 + return section
64 + }
65 +
66 + func cell(
67 + for item: any JomloItem,
68 + in collectionView: UICollectionView,
69 + at indexPath: IndexPath
70 + ) -> UICollectionViewCell {
71 + guard let resumeItem = item as? TVResumeItem else { return UICollectionViewCell() }
72 + return collectionView.dequeueJomloCell(TVResumeCell.self, for: indexPath, item: resumeItem)
73 + }
74 +
75 + func supplementaryView(
76 + ofKind kind: String,
77 + in collectionView: UICollectionView,
78 + at indexPath: IndexPath
79 + ) -> UICollectionReusableView? {
80 + guard kind == UICollectionView.elementKindSectionHeader else { return nil }
81 + guard
82 + let header = collectionView.dequeueReusableSupplementaryView(
83 + ofKind: kind,
84 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier,
85 + for: indexPath
86 + ) as? TVSectionHeaderView
87 + else { return nil }
88 + header.configure(title: "Continue Watching")
89 + return header
90 + }
91 +}
Example/ExampleApp_tvOS/Sections/TVShelfSection.swift new
+120
@@ -0,0 +1,120 @@
1 +//
2 +// TVShelfSection.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A horizontal shelf of poster cards sized for TV viewing distance,
13 +/// with a "See All" card appended as the final item in the shelf.
14 +///
15 +/// The last item renders as `TVSeeAllCell` — a dark card with a prompt
16 +/// and pink pill button, matching the Viaplay tvOS design.
17 +@MainActor
18 +final class TVShelfSection: JomloSection {
19 +
20 + let identifier: JomloSectionID
21 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
22 +
23 + private let title: String
24 + private let contentItems: [TVContentItem]
25 + weak var presenter: UIViewController?
26 +
27 + init(id: String, title: String, items: [TVContentItem], presenter: UIViewController? = nil) {
28 + self.identifier = JomloSectionID(id)
29 + self.title = title
30 + self.contentItems = items
31 + self.presenter = presenter
32 + }
33 +
34 + // MARK: - JomloSection
35 +
36 + /// Returns content items followed by a single `TVSeeAllItem` sentinel.
37 + func items() -> [any JomloItem] {
38 + let seeAll = TVSeeAllItem(id: "\(identifier.rawValue)-see-all", sectionTitle: title)
39 + return contentItems + [seeAll]
40 + }
41 +
42 + func layoutSection(
43 + environment: NSCollectionLayoutEnvironment,
44 + sectionIndex: Int
45 + ) -> NSCollectionLayoutSection {
46 + let itemSize = NSCollectionLayoutSize(
47 + widthDimension: .fractionalWidth(1.0),
48 + heightDimension: .fractionalHeight(1.0)
49 + )
50 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
51 +
52 + let groupSize = NSCollectionLayoutSize(
53 + widthDimension: .absolute(240),
54 + heightDimension: .absolute(420)
55 + )
56 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
57 +
58 + let section = NSCollectionLayoutSection(group: group)
59 + section.orthogonalScrollingBehavior = .continuous
60 + section.interGroupSpacing = 40
61 + section.contentInsets = NSDirectionalEdgeInsets(
62 + top: 10, leading: 80, bottom: 50, trailing: 80)
63 +
64 + let headerSize = NSCollectionLayoutSize(
65 + widthDimension: .fractionalWidth(1.0),
66 + heightDimension: .absolute(60)
67 + )
68 + let header = NSCollectionLayoutBoundarySupplementaryItem(
69 + layoutSize: headerSize,
70 + elementKind: UICollectionView.elementKindSectionHeader,
71 + alignment: .top
72 + )
73 + section.boundarySupplementaryItems = [header]
74 +
75 + return section
76 + }
77 +
78 + func cell(
79 + for item: any JomloItem,
80 + in collectionView: UICollectionView,
81 + at indexPath: IndexPath
82 + ) -> UICollectionViewCell {
83 + if let seeAllItem = item as? TVSeeAllItem {
84 + return collectionView.dequeueJomloCell(
85 + TVSeeAllCell.self, for: indexPath, item: seeAllItem)
86 + }
87 + guard let contentItem = item as? TVContentItem else { return UICollectionViewCell() }
88 + return collectionView.dequeueJomloCell(TVPosterCell.self, for: indexPath, item: contentItem)
89 + }
90 +
91 + func supplementaryView(
92 + ofKind kind: String,
93 + in collectionView: UICollectionView,
94 + at indexPath: IndexPath
95 + ) -> UICollectionReusableView? {
96 + guard kind == UICollectionView.elementKindSectionHeader else { return nil }
97 + guard
98 + let header = collectionView.dequeueReusableSupplementaryView(
99 + ofKind: kind,
100 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier,
101 + for: indexPath
102 + ) as? TVSectionHeaderView
103 + else { return nil }
104 + // Title only — "See All" is now the last shelf card, not the header
105 + header.configure(title: title)
106 + return header
107 + }
108 +
109 + func didSelectItem(_ item: any JomloItem, at indexPath: IndexPath) {
110 + if let seeAllItem = item as? TVSeeAllItem {
111 + let seeAllVC = TVSeeAllViewController(
112 + title: seeAllItem.sectionTitle, items: contentItems)
113 + presenter?.navigationController?.pushViewController(seeAllVC, animated: true)
114 + return
115 + }
116 + if let contentItem = item as? TVContentItem {
117 + print("[tvOS] Selected: \(contentItem.title)")
118 + }
119 + }
120 +}
Example/ExampleApp_tvOS/TVStreamingHomeViewController.swift new
+246
@@ -0,0 +1,246 @@
1 +//
2 +// TVStreamingHomeViewController.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// The main view controller for the tvOS example app.
13 +///
14 +/// Demonstrates a streaming-service home screen optimized for Apple TV:
15 +/// - Full-width hero section with group-paging for Siri Remote swipes
16 +/// - "Continue Watching" shelf with progress indicators
17 +/// - Multiple content shelves with large poster cards
18 +/// - Category navigation row
19 +/// - All cells use the tvOS focus engine with scale + shadow animations
20 +@MainActor
21 +final class TVStreamingHomeViewController: JomloCollectionViewController {
22 +
23 + init() {
24 + super.init(sections: [])
25 + }
26 +
27 + override func viewDidLoad() {
28 + super.viewDidLoad()
29 + view.backgroundColor = UIColor(white: 0.05, alpha: 1.0)
30 +
31 + // Register TV-optimized cells
32 + collectionView.registerJomloCell(TVHeroCell.self)
33 + collectionView.registerJomloCell(TVPosterCell.self)
34 + collectionView.registerJomloCell(TVSeeAllCell.self)
35 + collectionView.registerJomloCell(TVCategoryCell.self)
36 + collectionView.registerJomloCell(TVResumeCell.self)
37 +
38 + // Register TV section header
39 + collectionView.register(
40 + TVSectionHeaderView.self,
41 + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
42 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier
43 + )
44 +
45 + // Ensure cells can receive focus outside visible bounds during scrolling
46 + collectionView.clipsToBounds = false
47 + collectionView.remembersLastFocusedIndexPath = true
48 +
49 + loadContent()
50 + }
51 +
52 + // MARK: - Content Assembly
53 +
54 + private func loadContent() {
55 + let sections: [any JomloSection] = [
56 + makeHeroSection(),
57 + makeResumeSection(),
58 + makeTrendingShelf(),
59 + makeCategorySection(),
60 + makeSeriesShelf(),
61 + makeMoviesShelf(),
62 + makeSportsShelf(),
63 + ]
64 + setSections(sections, animated: false)
65 + }
66 +
67 + // MARK: - Section Factories
68 +
69 + private func makeHeroSection() -> TVHeroSection {
70 + TVHeroSection(items: [
71 + TVHeroItem(
72 + id: "hero-1",
73 + title: "The Last Kingdom",
74 + tagline: "The epic saga concludes",
75 + metadata: "Season 5 \u{2022} Drama \u{2022} 2022",
76 + gradientTop: "#1a237e",
77 + gradientBottom: "#0d47a1"
78 + ),
79 + TVHeroItem(
80 + id: "hero-2",
81 + title: "Formula 1:\nDrive to Survive",
82 + tagline: "Every race. Every rivalry.",
83 + metadata: "Season 6 \u{2022} Documentary \u{2022} 2024",
84 + gradientTop: "#b71c1c",
85 + gradientBottom: "#880e4f"
86 + ),
87 + TVHeroItem(
88 + id: "hero-3",
89 + title: "Premier League",
90 + tagline: "Every match. Every week. Live.",
91 + metadata: "Live Sport \u{2022} Football \u{2022} 2024-25",
92 + gradientTop: "#1b5e20",
93 + gradientBottom: "#004d40"
94 + ),
95 + TVHeroItem(
96 + id: "hero-4",
97 + title: "Dune: Part Two",
98 + tagline: "The legend continues",
99 + metadata: "2024 \u{2022} Sci-Fi \u{2022} 2h 46m",
100 + gradientTop: "#e65100",
101 + gradientBottom: "#bf360c"
102 + ),
103 + ])
104 + }
105 +
106 + private func makeResumeSection() -> TVResumeSection {
107 + TVResumeSection(items: [
108 + TVResumeItem(
109 + id: "r-1", title: "Breaking Bad", episodeInfo: "S3 E7 \u{2022} One Minute",
110 + progress: 0.65, colorHex: "#2e7d32", remainingTime: "18 min left"),
111 + TVResumeItem(
112 + id: "r-2", title: "The Bear", episodeInfo: "S2 E3 \u{2022} Sundae", progress: 0.3,
113 + colorHex: "#f57f17", remainingTime: "24 min left"),
114 + TVResumeItem(
115 + id: "r-3", title: "Succession", episodeInfo: "S4 E1 \u{2022} The Munsters",
116 + progress: 0.82, colorHex: "#1565c0", remainingTime: "11 min left"),
117 + TVResumeItem(
118 + id: "r-4", title: "Slow Horses", episodeInfo: "S3 E4 \u{2022} Uninvited",
119 + progress: 0.45, colorHex: "#6a1b9a", remainingTime: "28 min left"),
120 + TVResumeItem(
121 + id: "r-5", title: "True Detective", episodeInfo: "S4 E2 \u{2022} Night Country",
122 + progress: 0.15, colorHex: "#37474f", remainingTime: "52 min left"),
123 + ])
124 + }
125 +
126 + private func makeTrendingShelf() -> TVShelfSection {
127 + TVShelfSection(
128 + id: "trending",
129 + title: "Trending Now",
130 + items: [
131 + TVContentItem(
132 + id: "t-1", title: "Oppenheimer", subtitle: "2023 \u{2022} Drama",
133 + colorHex: "#ff6f00", isFeatured: true),
134 + TVContentItem(
135 + id: "t-2", title: "Killers of the\nFlower Moon",
136 + subtitle: "2023 \u{2022} Crime", colorHex: "#4e342e", isFeatured: false),
137 + TVContentItem(
138 + id: "t-3", title: "Past Lives", subtitle: "2023 \u{2022} Romance",
139 + colorHex: "#00838f", isFeatured: false),
140 + TVContentItem(
141 + id: "t-4", title: "Anatomy\nof a Fall", subtitle: "2023 \u{2022} Thriller",
142 + colorHex: "#283593", isFeatured: false),
143 + TVContentItem(
144 + id: "t-5", title: "The Holdovers", subtitle: "2023 \u{2022} Comedy",
145 + colorHex: "#2e7d32", isFeatured: false),
146 + TVContentItem(
147 + id: "t-6", title: "Poor Things", subtitle: "2023 \u{2022} Fantasy",
148 + colorHex: "#ad1457", isFeatured: true),
149 + TVContentItem(
150 + id: "t-7", title: "The Zone\nof Interest", subtitle: "2023 \u{2022} Drama",
151 + colorHex: "#37474f", isFeatured: false),
152 + ],
153 + presenter: self
154 + )
155 + }
156 +
157 + private func makeCategorySection() -> TVCategorySection {
158 + TVCategorySection(items: [
159 + TVCategoryItem(id: "cat-1", name: "Movies", icon: "\u{1F3AC}", colorHex: "#c62828"),
160 + TVCategoryItem(id: "cat-2", name: "Series", icon: "\u{1F4FA}", colorHex: "#6a1b9a"),
161 + TVCategoryItem(id: "cat-3", name: "Sport", icon: "\u{26BD}", colorHex: "#1565c0"),
162 + TVCategoryItem(id: "cat-4", name: "Kids", icon: "\u{1F9F8}", colorHex: "#2e7d32"),
163 + TVCategoryItem(
164 + id: "cat-5", name: "Documentaries", icon: "\u{1F30D}", colorHex: "#e65100"),
165 + TVCategoryItem(id: "cat-6", name: "Live TV", icon: "\u{1F4E1}", colorHex: "#283593"),
166 + ])
167 + }
168 +
169 + private func makeSeriesShelf() -> TVShelfSection {
170 + TVShelfSection(
171 + id: "series",
172 + title: "Popular Series",
173 + items: [
174 + TVContentItem(
175 + id: "s-1", title: "The Bear", subtitle: "2022\u{2013} \u{2022} Comedy",
176 + colorHex: "#e65100", isFeatured: true),
177 + TVContentItem(
178 + id: "s-2", title: "Slow Horses", subtitle: "2022\u{2013} \u{2022} Spy",
179 + colorHex: "#1a237e", isFeatured: false),
180 + TVContentItem(
181 + id: "s-3", title: "Severance", subtitle: "2022\u{2013} \u{2022} Sci-Fi",
182 + colorHex: "#004d40", isFeatured: false),
183 + TVContentItem(
184 + id: "s-4", title: "Shogun", subtitle: "2024 \u{2022} Historical",
185 + colorHex: "#b71c1c", isFeatured: true),
186 + TVContentItem(
187 + id: "s-5", title: "True Detective", subtitle: "2024 \u{2022} Crime",
188 + colorHex: "#311b92", isFeatured: false),
189 + TVContentItem(
190 + id: "s-6", title: "Fallout", subtitle: "2024 \u{2022} Sci-Fi",
191 + colorHex: "#33691e", isFeatured: false),
192 + ],
193 + presenter: self
194 + )
195 + }
196 +
197 + private func makeMoviesShelf() -> TVShelfSection {
198 + TVShelfSection(
199 + id: "movies",
200 + title: "New Releases",
201 + items: [
202 + TVContentItem(
203 + id: "m-1", title: "Dune:\nPart Two", subtitle: "2024 \u{2022} Sci-Fi",
204 + colorHex: "#ff8f00", isFeatured: true),
205 + TVContentItem(
206 + id: "m-2", title: "Civil War", subtitle: "2024 \u{2022} Action",
207 + colorHex: "#455a64", isFeatured: false),
208 + TVContentItem(
209 + id: "m-3", title: "Challengers", subtitle: "2024 \u{2022} Drama",
210 + colorHex: "#00695c", isFeatured: false),
211 + TVContentItem(
212 + id: "m-4", title: "Furiosa", subtitle: "2024 \u{2022} Action",
213 + colorHex: "#795548", isFeatured: false),
214 + TVContentItem(
215 + id: "m-5", title: "Inside Out 2", subtitle: "2024 \u{2022} Animation",
216 + colorHex: "#7b1fa2", isFeatured: true),
217 + ],
218 + presenter: self
219 + )
220 + }
221 +
222 + private func makeSportsShelf() -> TVShelfSection {
223 + TVShelfSection(
224 + id: "sports",
225 + title: "Live & Upcoming Sport",
226 + items: [
227 + TVContentItem(
228 + id: "sp-1", title: "Premier\nLeague", subtitle: "Live \u{2022} Football",
229 + colorHex: "#1a237e", isFeatured: true),
230 + TVContentItem(
231 + id: "sp-2", title: "Champions\nLeague", subtitle: "Tomorrow \u{2022} Football",
232 + colorHex: "#01579b", isFeatured: false),
233 + TVContentItem(
234 + id: "sp-3", title: "Formula 1", subtitle: "Sunday \u{2022} Racing",
235 + colorHex: "#b71c1c", isFeatured: true),
236 + TVContentItem(
237 + id: "sp-4", title: "Handball", subtitle: "Today \u{2022} Live",
238 + colorHex: "#4a148c", isFeatured: false),
239 + TVContentItem(
240 + id: "sp-5", title: "NHL", subtitle: "Tonight \u{2022} Ice Hockey",
241 + colorHex: "#1b5e20", isFeatured: false),
242 + ],
243 + presenter: self
244 + )
245 + }
246 +}
Example/ExampleApp_tvOS/TVTabBarController.swift new
+107
@@ -0,0 +1,107 @@
1 +//
2 +// TVTabBarController.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A tab bar controller mimicking the Viaplay tvOS app navigation structure.
12 +///
13 +/// The tvOS tab bar sits at the top of the screen and is revealed by swiping up
14 +/// on the Siri Remote. Each tab represents a content category page, and focus
15 +/// transitions from the tab bar to the selected view controller's content.
16 +///
17 +/// This implementation includes the focus-handoff pattern from Viaplay's
18 +/// `TabBarController` — when focus moves from the tab bar down to content,
19 +/// we redirect focus to the selected view controller for smooth navigation.
20 +@MainActor
21 +final class TVTabBarController: UITabBarController {
22 +
23 + private var shouldGiveFocusToSelectedViewController = false
24 +
25 + override func viewDidLoad() {
26 + super.viewDidLoad()
27 + view.backgroundColor = UIColor(white: 0.05, alpha: 1.0)
28 + setupTabs()
29 + }
30 +
31 + // MARK: - Focus Engine
32 +
33 + /// Redirects focus to the selected view controller when transitioning
34 + /// from the tab bar down to content — ensures the BlocksViewController
35 + /// (or equivalent) gains proper focus control.
36 + override var preferredFocusEnvironments: [UIFocusEnvironment] {
37 + guard let selectedViewController, shouldGiveFocusToSelectedViewController else {
38 + return super.preferredFocusEnvironments
39 + }
40 + shouldGiveFocusToSelectedViewController = false
41 + return [selectedViewController]
42 + }
43 +
44 + /// Intercepts focus transitions from the tab bar to content.
45 + /// When focus moves away from the tab bar downward, we set a flag
46 + /// and force a focus update so `preferredFocusEnvironments` can
47 + /// hand focus to the selected view controller cleanly.
48 + override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool {
49 + if let nextFocusedView = context.nextFocusedView,
50 + !nextFocusedView.isDescendant(of: tabBar),
51 + let previouslyFocusedView = context.previouslyFocusedView,
52 + previouslyFocusedView.isDescendant(of: tabBar)
53 + {
54 + shouldGiveFocusToSelectedViewController = true
55 + setNeedsFocusUpdate()
56 + updateFocusIfNeeded()
57 + return false
58 + }
59 +
60 + shouldGiveFocusToSelectedViewController = false
61 + return true
62 + }
63 +
64 + // MARK: - Tab Configuration
65 +
66 + private func setupTabs() {
67 + let homeVC = TVStreamingHomeViewController()
68 + homeVC.tabBarItem = UITabBarItem(title: "Home", image: nil, tag: 0)
69 + homeVC.tabBarItem.accessibilityIdentifier = "Start"
70 + let homeNav = UINavigationController(rootViewController: homeVC)
71 +
72 + let seriesVC = TVSeriesViewController()
73 + seriesVC.tabBarItem = UITabBarItem(title: "Series", image: nil, tag: 1)
74 + seriesVC.tabBarItem.accessibilityIdentifier = "Series"
75 + let seriesNav = UINavigationController(rootViewController: seriesVC)
76 +
77 + let moviesVC = TVMoviesViewController()
78 + moviesVC.tabBarItem = UITabBarItem(title: "Movies", image: nil, tag: 2)
79 + moviesVC.tabBarItem.accessibilityIdentifier = "Movies"
80 + let moviesNav = UINavigationController(rootViewController: moviesVC)
81 +
82 + let sportsVC = TVSportsViewController()
83 + sportsVC.tabBarItem = UITabBarItem(title: "Sport", image: nil, tag: 3)
84 + sportsVC.tabBarItem.accessibilityIdentifier = "Sports"
85 + let sportsNav = UINavigationController(rootViewController: sportsVC)
86 +
87 + let kidsVC = TVKidsViewController()
88 + kidsVC.tabBarItem = UITabBarItem(title: "Kids", image: nil, tag: 4)
89 + kidsVC.tabBarItem.accessibilityIdentifier = "Kids"
90 + let kidsNav = UINavigationController(rootViewController: kidsVC)
91 +
92 + let searchVC = TVSearchViewController()
93 + searchVC.tabBarItem = UITabBarItem(title: "Search", image: nil, tag: 5)
94 + searchVC.tabBarItem.accessibilityIdentifier = "Search"
95 + let searchNav = UINavigationController(rootViewController: searchVC)
96 +
97 + // Copy tabBarItem from the root VC to the nav so the tab bar shows titles
98 + homeNav.tabBarItem = homeVC.tabBarItem
99 + seriesNav.tabBarItem = seriesVC.tabBarItem
100 + moviesNav.tabBarItem = moviesVC.tabBarItem
101 + sportsNav.tabBarItem = sportsVC.tabBarItem
102 + kidsNav.tabBarItem = kidsVC.tabBarItem
103 + searchNav.tabBarItem = searchVC.tabBarItem
104 +
105 + viewControllers = [homeNav, seriesNav, moviesNav, sportsNav, kidsNav, searchNav]
106 + }
107 +}
Example/ExampleApp_tvOS/TVTabViewControllers.swift new
+417
@@ -0,0 +1,417 @@
1 +//
2 +// TVTabViewControllers.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +// MARK: - Series Tab
13 +
14 +/// The "Series" tab — shows popular and new series shelves.
15 +@MainActor
16 +final class TVSeriesViewController: JomloCollectionViewController {
17 +
18 + init() {
19 + super.init(sections: [])
20 + }
21 +
22 + override func viewDidLoad() {
23 + super.viewDidLoad()
24 + view.backgroundColor = UIColor(white: 0.05, alpha: 1.0)
25 + registerCells()
26 + loadContent()
27 + }
28 +
29 + private func registerCells() {
30 + collectionView.registerJomloCell(TVPosterCell.self)
31 + collectionView.registerJomloCell(TVSeeAllCell.self)
32 + collectionView.register(
33 + TVSectionHeaderView.self,
34 + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
35 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier
36 + )
37 + collectionView.clipsToBounds = false
38 + collectionView.remembersLastFocusedIndexPath = true
39 + }
40 +
41 + private func loadContent() {
42 + setSections(
43 + [
44 + TVShelfSection(
45 + id: "series-popular", title: "Popular Series",
46 + items: [
47 + TVContentItem(
48 + id: "sp-1", title: "The Bear", subtitle: "2022\u{2013} \u{2022} Comedy",
49 + colorHex: "#e65100", isFeatured: true),
50 + TVContentItem(
51 + id: "sp-2", title: "Slow Horses", subtitle: "2022\u{2013} \u{2022} Spy",
52 + colorHex: "#1a237e", isFeatured: false),
53 + TVContentItem(
54 + id: "sp-3", title: "Severance",
55 + subtitle: "2022\u{2013} \u{2022} Sci-Fi", colorHex: "#004d40",
56 + isFeatured: false),
57 + TVContentItem(
58 + id: "sp-4", title: "Shogun", subtitle: "2024 \u{2022} Historical",
59 + colorHex: "#b71c1c", isFeatured: true),
60 + TVContentItem(
61 + id: "sp-5", title: "True Detective", subtitle: "2024 \u{2022} Crime",
62 + colorHex: "#311b92", isFeatured: false),
63 + TVContentItem(
64 + id: "sp-6", title: "Fallout", subtitle: "2024 \u{2022} Sci-Fi",
65 + colorHex: "#33691e", isFeatured: false),
66 + ],
67 +
68 + presenter: self),
69 + TVShelfSection(
70 + id: "series-new", title: "New Episodes This Week",
71 + items: [
72 + TVContentItem(
73 + id: "sn-1", title: "House of\nthe Dragon",
74 + subtitle: "S2 \u{2022} Fantasy", colorHex: "#880e4f", isFeatured: true),
75 + TVContentItem(
76 + id: "sn-2", title: "The Diplomat", subtitle: "S2 \u{2022} Thriller",
77 + colorHex: "#1565c0", isFeatured: false),
78 + TVContentItem(
79 + id: "sn-3", title: "Industry", subtitle: "S3 \u{2022} Drama",
80 + colorHex: "#37474f", isFeatured: false),
81 + TVContentItem(
82 + id: "sn-4", title: "Bad Sisters", subtitle: "S2 \u{2022} Comedy",
83 + colorHex: "#2e7d32", isFeatured: false),
84 + TVContentItem(
85 + id: "sn-5", title: "Reacher", subtitle: "S3 \u{2022} Action",
86 + colorHex: "#4e342e", isFeatured: false),
87 + ],
88 +
89 + presenter: self),
90 + TVShelfSection(
91 + id: "series-scandi", title: "Scandinavian Originals",
92 + items: [
93 + TVContentItem(
94 + id: "sc-1", title: "The Bridge", subtitle: "Crime \u{2022} Thriller",
95 + colorHex: "#455a64", isFeatured: false),
96 + TVContentItem(
97 + id: "sc-2", title: "Borgen", subtitle: "Political \u{2022} Drama",
98 + colorHex: "#00695c", isFeatured: false),
99 + TVContentItem(
100 + id: "sc-3", title: "Beforeigners", subtitle: "Sci-Fi \u{2022} Crime",
101 + colorHex: "#283593", isFeatured: true),
102 + TVContentItem(
103 + id: "sc-4", title: "The Rain", subtitle: "Sci-Fi \u{2022} Drama",
104 + colorHex: "#1b5e20", isFeatured: false),
105 + TVContentItem(
106 + id: "sc-5", title: "Kamikaze", subtitle: "Drama", colorHex: "#6a1b9a",
107 + isFeatured: false),
108 + ],
109 +
110 + presenter: self),
111 + ], animated: false)
112 + }
113 +}
114 +
115 +// MARK: - Movies Tab
116 +
117 +/// The "Movies" tab — shows featured films and categories.
118 +@MainActor
119 +final class TVMoviesViewController: JomloCollectionViewController {
120 +
121 + init() {
122 + super.init(sections: [])
123 + }
124 +
125 + override func viewDidLoad() {
126 + super.viewDidLoad()
127 + view.backgroundColor = UIColor(white: 0.05, alpha: 1.0)
128 + registerCells()
129 + loadContent()
130 + }
131 +
132 + private func registerCells() {
133 + collectionView.registerJomloCell(TVPosterCell.self)
134 + collectionView.registerJomloCell(TVSeeAllCell.self)
135 + collectionView.register(
136 + TVSectionHeaderView.self,
137 + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
138 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier
139 + )
140 + collectionView.clipsToBounds = false
141 + collectionView.remembersLastFocusedIndexPath = true
142 + }
143 +
144 + private func loadContent() {
145 + setSections(
146 + [
147 + TVShelfSection(
148 + id: "movies-new", title: "New Releases",
149 + items: [
150 + TVContentItem(
151 + id: "mn-1", title: "Dune:\nPart Two", subtitle: "2024 \u{2022} Sci-Fi",
152 + colorHex: "#ff8f00", isFeatured: true),
153 + TVContentItem(
154 + id: "mn-2", title: "Civil War", subtitle: "2024 \u{2022} Action",
155 + colorHex: "#455a64", isFeatured: false),
156 + TVContentItem(
157 + id: "mn-3", title: "Challengers", subtitle: "2024 \u{2022} Drama",
158 + colorHex: "#00695c", isFeatured: false),
159 + TVContentItem(
160 + id: "mn-4", title: "Furiosa", subtitle: "2024 \u{2022} Action",
161 + colorHex: "#795548", isFeatured: false),
162 + TVContentItem(
163 + id: "mn-5", title: "Inside Out 2", subtitle: "2024 \u{2022} Animation",
164 + colorHex: "#7b1fa2", isFeatured: true),
165 + ],
166 +
167 + presenter: self),
168 + TVShelfSection(
169 + id: "movies-acclaimed", title: "Critically Acclaimed",
170 + items: [
171 + TVContentItem(
172 + id: "ma-1", title: "Oppenheimer", subtitle: "2023 \u{2022} Drama",
173 + colorHex: "#ff6f00", isFeatured: true),
174 + TVContentItem(
175 + id: "ma-2", title: "Past Lives", subtitle: "2023 \u{2022} Romance",
176 + colorHex: "#00838f", isFeatured: false),
177 + TVContentItem(
178 + id: "ma-3", title: "Anatomy\nof a Fall",
179 + subtitle: "2023 \u{2022} Thriller", colorHex: "#283593",
180 + isFeatured: false),
181 + TVContentItem(
182 + id: "ma-4", title: "The Holdovers", subtitle: "2023 \u{2022} Comedy",
183 + colorHex: "#2e7d32", isFeatured: false),
184 + TVContentItem(
185 + id: "ma-5", title: "Poor Things", subtitle: "2023 \u{2022} Fantasy",
186 + colorHex: "#ad1457", isFeatured: true),
187 + TVContentItem(
188 + id: "ma-6", title: "Zone of\nInterest", subtitle: "2023 \u{2022} Drama",
189 + colorHex: "#37474f", isFeatured: false),
190 + ],
191 +
192 + presenter: self),
193 + TVShelfSection(
194 + id: "movies-family", title: "Family Movies",
195 + items: [
196 + TVContentItem(
197 + id: "mf-1", title: "Wish", subtitle: "2023 \u{2022} Animation",
198 + colorHex: "#4527a0", isFeatured: false),
199 + TVContentItem(
200 + id: "mf-2", title: "Migration", subtitle: "2023 \u{2022} Animation",
201 + colorHex: "#00838f", isFeatured: false),
202 + TVContentItem(
203 + id: "mf-3", title: "Wonka", subtitle: "2023 \u{2022} Fantasy",
204 + colorHex: "#6a1b9a", isFeatured: true),
205 + TVContentItem(
206 + id: "mf-4", title: "Elemental", subtitle: "2023 \u{2022} Animation",
207 + colorHex: "#e65100", isFeatured: false),
208 + ],
209 +
210 + presenter: self),
211 + ], animated: false)
212 + }
213 +}
214 +
215 +// MARK: - Sports Tab
216 +
217 +/// The "Sport" tab — shows live and upcoming sports content.
218 +@MainActor
219 +final class TVSportsViewController: JomloCollectionViewController {
220 +
221 + init() {
222 + super.init(sections: [])
223 + }
224 +
225 + override func viewDidLoad() {
226 + super.viewDidLoad()
227 + view.backgroundColor = UIColor(white: 0.05, alpha: 1.0)
228 + registerCells()
229 + loadContent()
230 + }
231 +
232 + private func registerCells() {
233 + collectionView.registerJomloCell(TVPosterCell.self)
234 + collectionView.registerJomloCell(TVSeeAllCell.self)
235 + collectionView.register(
236 + TVSectionHeaderView.self,
237 + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
238 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier
239 + )
240 + collectionView.clipsToBounds = false
241 + collectionView.remembersLastFocusedIndexPath = true
242 + }
243 +
244 + private func loadContent() {
245 + setSections(
246 + [
247 + TVShelfSection(
248 + id: "sports-live", title: "Live Now",
249 + items: [
250 + TVContentItem(
251 + id: "sl-1", title: "Premier\nLeague",
252 + subtitle: "Live \u{2022} Football", colorHex: "#1a237e",
253 + isFeatured: true),
254 + TVContentItem(
255 + id: "sl-2", title: "Handball\nChampions",
256 + subtitle: "Live \u{2022} Handball", colorHex: "#4a148c",
257 + isFeatured: false),
258 + ],
259 +
260 + presenter: self),
261 + TVShelfSection(
262 + id: "sports-upcoming", title: "Upcoming",
263 + items: [
264 + TVContentItem(
265 + id: "su-1", title: "Champions\nLeague",
266 + subtitle: "Tomorrow \u{2022} Football", colorHex: "#01579b",
267 + isFeatured: false),
268 + TVContentItem(
269 + id: "su-2", title: "Formula 1", subtitle: "Sunday \u{2022} Racing",
270 + colorHex: "#b71c1c", isFeatured: true),
271 + TVContentItem(
272 + id: "su-3", title: "NHL", subtitle: "Tonight \u{2022} Ice Hockey",
273 + colorHex: "#1b5e20", isFeatured: false),
274 + TVContentItem(
275 + id: "su-4", title: "UFC 310", subtitle: "Saturday \u{2022} MMA",
276 + colorHex: "#880e4f", isFeatured: false),
277 + ],
278 +
279 + presenter: self),
280 + TVShelfSection(
281 + id: "sports-replays", title: "Replays & Highlights",
282 + items: [
283 + TVContentItem(
284 + id: "sr-1", title: "Match of\nthe Day", subtitle: "Football",
285 + colorHex: "#2e7d32", isFeatured: false),
286 + TVContentItem(
287 + id: "sr-2", title: "F1 Highlights", subtitle: "Racing",
288 + colorHex: "#c62828", isFeatured: false),
289 + TVContentItem(
290 + id: "sr-3", title: "Golf\nMajors", subtitle: "Golf",
291 + colorHex: "#33691e", isFeatured: false),
292 + TVContentItem(
293 + id: "sr-4", title: "Biathlon\nWorld Cup", subtitle: "Winter Sport",
294 + colorHex: "#0d47a1", isFeatured: false),
295 + ],
296 +
297 + presenter: self),
298 + ], animated: false)
299 + }
300 +}
301 +
302 +// MARK: - Kids Tab
303 +
304 +/// The "Kids" tab — shows children's content.
305 +@MainActor
306 +final class TVKidsViewController: JomloCollectionViewController {
307 +
308 + init() {
309 + super.init(sections: [])
310 + }
311 +
312 + override func viewDidLoad() {
313 + super.viewDidLoad()
314 + view.backgroundColor = UIColor(white: 0.08, alpha: 1.0)
315 + registerCells()
316 + loadContent()
317 + }
318 +
319 + private func registerCells() {
320 + collectionView.registerJomloCell(TVPosterCell.self)
321 + collectionView.registerJomloCell(TVSeeAllCell.self)
322 + collectionView.register(
323 + TVSectionHeaderView.self,
324 + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
325 + withReuseIdentifier: TVSectionHeaderView.reuseIdentifier
326 + )
327 + collectionView.clipsToBounds = false
328 + collectionView.remembersLastFocusedIndexPath = true
329 + }
330 +
331 + private func loadContent() {
332 + setSections(
333 + [
334 + TVShelfSection(
335 + id: "kids-popular", title: "Popular with Kids",
336 + items: [
337 + TVContentItem(
338 + id: "kp-1", title: "Peppa Pig", subtitle: "Animation",
339 + colorHex: "#e91e63", isFeatured: true),
340 + TVContentItem(
341 + id: "kp-2", title: "Paw Patrol", subtitle: "Animation",
342 + colorHex: "#1565c0", isFeatured: false),
343 + TVContentItem(
344 + id: "kp-3", title: "Bluey", subtitle: "Animation", colorHex: "#0097a7",
345 + isFeatured: true),
346 + TVContentItem(
347 + id: "kp-4", title: "CoComelon", subtitle: "Animation",
348 + colorHex: "#f57f17", isFeatured: false),
349 + TVContentItem(
350 + id: "kp-5", title: "Lego Friends", subtitle: "Animation",
351 + colorHex: "#7b1fa2", isFeatured: false),
352 + ],
353 +
354 + presenter: self),
355 + TVShelfSection(
356 + id: "kids-movies", title: "Kids Movies",
357 + items: [
358 + TVContentItem(
359 + id: "km-1", title: "Frozen II", subtitle: "2019 \u{2022} Animation",
360 + colorHex: "#4fc3f7", isFeatured: true),
361 + TVContentItem(
362 + id: "km-2", title: "Moana", subtitle: "2016 \u{2022} Animation",
363 + colorHex: "#00897b", isFeatured: false),
364 + TVContentItem(
365 + id: "km-3", title: "Encanto", subtitle: "2021 \u{2022} Animation",
366 + colorHex: "#ff8f00", isFeatured: false),
367 + TVContentItem(
368 + id: "km-4", title: "Luca", subtitle: "2021 \u{2022} Animation",
369 + colorHex: "#1565c0", isFeatured: false),
370 + ],
371 +
372 + presenter: self),
373 + ], animated: false)
374 + }
375 +}
376 +
377 +// MARK: - Search Tab
378 +
379 +/// The "Search" tab — placeholder for search functionality.
380 +@MainActor
381 +final class TVSearchViewController: UIViewController {
382 +
383 + private let messageLabel: UILabel = {
384 + let label = UILabel()
385 + label.text = "Search"
386 + label.font = .systemFont(ofSize: 42, weight: .medium)
387 + label.textColor = UIColor.white.withAlphaComponent(0.6)
388 + label.textAlignment = .center
389 + return label
390 + }()
391 +
392 + private let subtitleLabel: UILabel = {
393 + let label = UILabel()
394 + label.text = "Type to search for movies, series, and sport"
395 + label.font = .systemFont(ofSize: 28, weight: .regular)
396 + label.textColor = UIColor.white.withAlphaComponent(0.4)
397 + label.textAlignment = .center
398 + return label
399 + }()
400 +
401 + override func viewDidLoad() {
402 + super.viewDidLoad()
403 + view.backgroundColor = UIColor(white: 0.05, alpha: 1.0)
404 +
405 + let stack = UIStackView(arrangedSubviews: [messageLabel, subtitleLabel])
406 + stack.axis = .vertical
407 + stack.spacing = 16
408 + stack.alignment = .center
409 + stack.translatesAutoresizingMaskIntoConstraints = false
410 +
411 + view.addSubview(stack)
412 + NSLayoutConstraint.activate([
413 + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
414 + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
415 + ])
416 + }
417 +}
Example/ExampleApp_tvOS/ViewControllers/TVSeeAllViewController.swift new
+147
@@ -0,0 +1,147 @@
1 +//
2 +// TVSeeAllViewController.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import JomloCollectionView
10 +import UIKit
11 +
12 +/// A tvOS view controller that simulates loading content from a network request,
13 +/// then displays the results in a grid optimized for the big screen.
14 +///
15 +/// Inspired by Viaplay's `LinkLoadingViewController` — shows a loading state
16 +/// while "fetching" page data, then transitions to a focusable content grid.
17 +@MainActor
18 +final class TVSeeAllViewController: JomloCollectionViewController {
19 +
20 + private let sectionTitle: String
21 + private let contentItems: [TVContentItem]
22 +
23 + private lazy var loadingView: UIView = {
24 + let container = UIView()
25 + container.translatesAutoresizingMaskIntoConstraints = false
26 +
27 + let spinner = UIActivityIndicatorView(style: .large)
28 + spinner.color = .white
29 + spinner.startAnimating()
30 + spinner.translatesAutoresizingMaskIntoConstraints = false
31 +
32 + let label = UILabel()
33 + label.text = "Loading..."
34 + label.font = .systemFont(ofSize: 30, weight: .medium)
35 + label.textColor = UIColor.white.withAlphaComponent(0.5)
36 + label.translatesAutoresizingMaskIntoConstraints = false
37 +
38 + container.addSubview(spinner)
39 + container.addSubview(label)
40 +
41 + NSLayoutConstraint.activate([
42 + spinner.centerXAnchor.constraint(equalTo: container.centerXAnchor),
43 + spinner.centerYAnchor.constraint(equalTo: container.centerYAnchor, constant: -20),
44 + label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
45 + label.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 20),
46 + ])
47 +
48 + return container
49 + }()
50 +
51 + init(title: String, items: [TVContentItem]) {
52 + self.sectionTitle = title
53 + self.contentItems = items
54 + super.init(sections: [])
55 + }
56 +
57 + override func viewDidLoad() {
58 + super.viewDidLoad()
59 + self.title = sectionTitle
60 + view.backgroundColor = UIColor(white: 0.05, alpha: 1.0)
61 +
62 + collectionView.registerJomloCell(TVPosterCell.self)
63 + collectionView.clipsToBounds = false
64 + collectionView.remembersLastFocusedIndexPath = true
65 +
66 + showLoading()
67 + simulateNetworkRequest()
68 + }
69 +
70 + private func showLoading() {
71 + collectionView.isHidden = true
72 + view.addSubview(loadingView)
73 + NSLayoutConstraint.activate([
74 + loadingView.topAnchor.constraint(equalTo: view.topAnchor),
75 + loadingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
76 + loadingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
77 + loadingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
78 + ])
79 + }
80 +
81 + private func simulateNetworkRequest() {
82 + Task { @MainActor in
83 + try? await Task.sleep(for: .seconds(1.5))
84 + showContent()
85 + }
86 + }
87 +
88 + private func showContent() {
89 + loadingView.removeFromSuperview()
90 + collectionView.isHidden = false
91 +
92 + let gridSection = TVSeeAllGridSection(items: contentItems)
93 + setSections([gridSection], animated: true)
94 + }
95 +}
96 +
97 +// MARK: - Grid Section for tvOS "See All" page
98 +
99 +@MainActor
100 +private final class TVSeeAllGridSection: JomloSection {
101 +
102 + let identifier = JomloSectionID("tv-see-all-grid")
103 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
104 +
105 + private let gridItems: [TVContentItem]
106 +
107 + init(items: [TVContentItem]) {
108 + self.gridItems = items
109 + }
110 +
111 + func items() -> [any JomloItem] {
112 + gridItems
113 + }
114 +
115 + func layoutSection(
116 + environment: NSCollectionLayoutEnvironment,
117 + sectionIndex: Int
118 + ) -> NSCollectionLayoutSection {
119 + // 4 columns on TV for a nice grid
120 + let itemSize = NSCollectionLayoutSize(
121 + widthDimension: .fractionalWidth(0.25),
122 + heightDimension: .absolute(420)
123 + )
124 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
125 + item.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
126 +
127 + let groupSize = NSCollectionLayoutSize(
128 + widthDimension: .fractionalWidth(1.0),
129 + heightDimension: .absolute(420)
130 + )
131 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
132 +
133 + let section = NSCollectionLayoutSection(group: group)
134 + section.contentInsets = NSDirectionalEdgeInsets(
135 + top: 40, leading: 60, bottom: 40, trailing: 60)
136 + return section
137 + }
138 +
139 + func cell(
140 + for item: any JomloItem,
141 + in collectionView: UICollectionView,
142 + at indexPath: IndexPath
143 + ) -> UICollectionViewCell {
144 + guard let contentItem = item as? TVContentItem else { return UICollectionViewCell() }
145 + return collectionView.dequeueJomloCell(TVPosterCell.self, for: indexPath, item: contentItem)
146 + }
147 +}
Example/ExampleApp_tvOS/Views/TVColorExtension.swift new
+25
@@ -0,0 +1,25 @@
1 +//
2 +// TVColorExtension.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +extension UIColor {
12 + convenience init?(hex: String) {
13 + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
14 + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
15 +
16 + var rgb: UInt64 = 0
17 + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
18 +
19 + let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
20 + let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
21 + let b = CGFloat(rgb & 0x0000FF) / 255.0
22 +
23 + self.init(red: r, green: g, blue: b, alpha: 1.0)
24 + }
25 +}
Example/ExampleApp_tvOS/Views/TVSectionHeaderView.swift new
+83
@@ -0,0 +1,83 @@
1 +//
2 +// TVSectionHeaderView.swift
3 +// JomloCV tvOS Example
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A section header designed for TV — large, bold title with an optional focusable "See All" button.
12 +///
13 +/// On tvOS, the "See All" button is a proper `UIButton` that participates in the
14 +/// focus engine. It receives the standard tvOS focus styling automatically.
15 +final class TVSectionHeaderView: UICollectionReusableView {
16 +
17 + static let reuseIdentifier = "TVSectionHeaderView"
18 +
19 + private let titleLabel: UILabel = {
20 + let label = UILabel()
21 + label.font = .systemFont(ofSize: 34, weight: .bold)
22 + label.textColor = .white
23 + return label
24 + }()
25 +
26 + private let seeAllButton: UIButton = {
27 + let button = UIButton(type: .system)
28 + button.setTitle("See All", for: .normal)
29 + button.titleLabel?.font = .systemFont(ofSize: 26, weight: .medium)
30 + button.setTitleColor(UIColor.white.withAlphaComponent(0.7), for: .normal)
31 + button.isHidden = true
32 + // tvOS focus engine will automatically apply a scale + shadow on focus
33 + button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
34 + return button
35 + }()
36 +
37 + private var onSeeAllTapped: (@MainActor @Sendable () -> Void)?
38 +
39 + override init(frame: CGRect) {
40 + super.init(frame: frame)
41 + setupViews()
42 + }
43 +
44 + required init?(coder: NSCoder) {
45 + fatalError("init(coder:) not supported")
46 + }
47 +
48 + /// Configures the header.
49 + ///
50 + /// - Parameters:
51 + /// - title: The section title.
52 + /// - action: Optional closure called when "See All" is selected. Pass `nil` to hide the button.
53 + func configure(title: String, action: (@MainActor @Sendable () -> Void)? = nil) {
54 + titleLabel.text = title
55 + if let action {
56 + onSeeAllTapped = action
57 + seeAllButton.isHidden = false
58 + } else {
59 + onSeeAllTapped = nil
60 + seeAllButton.isHidden = true
61 + }
62 + }
63 +
64 + private func setupViews() {
65 + let stack = UIStackView(arrangedSubviews: [titleLabel, UIView(), seeAllButton])
66 + stack.axis = .horizontal
67 + stack.alignment = .center
68 + stack.translatesAutoresizingMaskIntoConstraints = false
69 +
70 + addSubview(stack)
71 + NSLayoutConstraint.activate([
72 + stack.leadingAnchor.constraint(equalTo: leadingAnchor),
73 + stack.trailingAnchor.constraint(equalTo: trailingAnchor),
74 + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
75 + ])
76 +
77 + seeAllButton.addTarget(self, action: #selector(seeAllPressed), for: .primaryActionTriggered)
78 + }
79 +
80 + @objc private func seeAllPressed() {
81 + onSeeAllTapped?()
82 + }
83 +}
Example/JomloCollectionViewExample.xcodeproj/project.pbxproj new
+600
@@ -0,0 +1,600 @@
1 +// !$*UTF8*$!
2 +{
3 + archiveVersion = 1;
4 + classes = {
5 + };
6 + objectVersion = 60;
7 + objects = {
8 +
9 +/* Begin PBXBuildFile section */
10 + /* iOS Sources */
11 + A1000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001 /* AppDelegate.swift */; };
12 + A1000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000002 /* SceneDelegate.swift */; };
13 + A1000003 /* StreamingHomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000003 /* StreamingHomeViewController.swift */; };
14 + A1000004 /* HeroCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000004 /* HeroCell.swift */; };
15 + A1000005 /* PosterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000005 /* PosterCell.swift */; };
16 + A1000006 /* CategoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000006 /* CategoryCell.swift */; };
17 + A1000007 /* ContinueWatchingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000007 /* ContinueWatchingCell.swift */; };
18 + A1000008 /* HeroItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000008 /* HeroItem.swift */; };
19 + A1000009 /* PosterItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000009 /* PosterItem.swift */; };
20 + A100000A /* CategoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000A /* CategoryItem.swift */; };
21 + A100000B /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000B /* ContinueWatchingItem.swift */; };
22 + A100000C /* HeroSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000C /* HeroSection.swift */; };
23 + A100000D /* ShelfSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000D /* ShelfSection.swift */; };
24 + A100000E /* CategoriesGridSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000E /* CategoriesGridSection.swift */; };
25 + A100000F /* ContinueWatchingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000F /* ContinueWatchingSection.swift */; };
26 + A1000010 /* JomloCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = D1000001 /* JomloCollectionView */; };
27 + A1000011 /* SeeAllViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000011 /* SeeAllViewController.swift */; };
28 +
29 + /* tvOS Sources */
30 + A2000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000001 /* AppDelegate.swift */; };
31 + A200002C /* TVTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200002C /* TVTabBarController.swift */; };
32 + A200002E /* TVSeeAllViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200002E /* TVSeeAllViewController.swift */; };
33 + A200002D /* TVTabViewControllers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200002D /* TVTabViewControllers.swift */; };
34 + A2000020 /* TVStreamingHomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000010 /* TVStreamingHomeViewController.swift */; };
35 + A2000021 /* TVContentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000011 /* TVContentItem.swift */; };
36 + A2000022 /* TVHeroCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000012 /* TVHeroCell.swift */; };
37 + A200002F /* TVSeeAllCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200002F /* TVSeeAllCell.swift */; };
38 + A2000023 /* TVPosterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000013 /* TVPosterCell.swift */; };
39 + A2000024 /* TVCategoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000014 /* TVCategoryCell.swift */; };
40 + A2000025 /* TVResumeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000015 /* TVResumeCell.swift */; };
41 + A2000026 /* TVHeroSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000016 /* TVHeroSection.swift */; };
42 + A2000027 /* TVShelfSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000017 /* TVShelfSection.swift */; };
43 + A2000028 /* TVResumeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000018 /* TVResumeSection.swift */; };
44 + A2000029 /* TVCategorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000019 /* TVCategorySection.swift */; };
45 + A200002A /* TVSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200001A /* TVSectionHeaderView.swift */; };
46 + A200002B /* TVColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200001B /* TVColorExtension.swift */; };
47 + A2000010 /* JomloCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = D2000001 /* JomloCollectionView */; };
48 +/* End PBXBuildFile section */
49 +
50 +/* Begin PBXFileReference section */
51 + /* iOS App */
52 + B1000001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
53 + B1000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
54 + B1000003 /* StreamingHomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingHomeViewController.swift; sourceTree = "<group>"; };
55 + B1000011 /* SeeAllViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeeAllViewController.swift; sourceTree = "<group>"; };
56 + B1000004 /* HeroCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeroCell.swift; sourceTree = "<group>"; };
57 + B1000005 /* PosterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterCell.swift; sourceTree = "<group>"; };
58 + B1000006 /* CategoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCell.swift; sourceTree = "<group>"; };
59 + B1000007 /* ContinueWatchingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCell.swift; sourceTree = "<group>"; };
60 + B1000008 /* HeroItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeroItem.swift; sourceTree = "<group>"; };
61 + B1000009 /* PosterItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterItem.swift; sourceTree = "<group>"; };
62 + B100000A /* CategoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryItem.swift; sourceTree = "<group>"; };
63 + B100000B /* ContinueWatchingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingItem.swift; sourceTree = "<group>"; };
64 + B100000C /* HeroSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeroSection.swift; sourceTree = "<group>"; };
65 + B100000D /* ShelfSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSection.swift; sourceTree = "<group>"; };
66 + B100000E /* CategoriesGridSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesGridSection.swift; sourceTree = "<group>"; };
67 + B100000F /* ContinueWatchingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingSection.swift; sourceTree = "<group>"; };
68 + B1000020 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
69 +
70 + /* tvOS App */
71 + B2000001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
72 + B200002C /* TVTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVTabBarController.swift; sourceTree = "<group>"; };
73 + B200002E /* TVSeeAllViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVSeeAllViewController.swift; sourceTree = "<group>"; };
74 + B200002D /* TVTabViewControllers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVTabViewControllers.swift; sourceTree = "<group>"; };
75 + B2000010 /* TVStreamingHomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVStreamingHomeViewController.swift; sourceTree = "<group>"; };
76 + B2000011 /* TVContentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVContentItem.swift; sourceTree = "<group>"; };
77 + B2000012 /* TVHeroCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVHeroCell.swift; sourceTree = "<group>"; };
78 + B200002F /* TVSeeAllCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVSeeAllCell.swift; sourceTree = "<group>"; };
79 + B2000013 /* TVPosterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVPosterCell.swift; sourceTree = "<group>"; };
80 + B2000014 /* TVCategoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCategoryCell.swift; sourceTree = "<group>"; };
81 + B2000015 /* TVResumeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVResumeCell.swift; sourceTree = "<group>"; };
82 + B2000016 /* TVHeroSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVHeroSection.swift; sourceTree = "<group>"; };
83 + B2000017 /* TVShelfSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVShelfSection.swift; sourceTree = "<group>"; };
84 + B2000018 /* TVResumeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVResumeSection.swift; sourceTree = "<group>"; };
85 + B2000019 /* TVCategorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCategorySection.swift; sourceTree = "<group>"; };
86 + B200001A /* TVSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVSectionHeaderView.swift; sourceTree = "<group>"; };
87 + B200001B /* TVColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVColorExtension.swift; sourceTree = "<group>"; };
88 + B2000020 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
89 +
90 + /* Products */
91 + C1000001 /* JomloCV Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "JomloCV Example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
92 + C2000001 /* JomloCV tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "JomloCV tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
93 +
94 + /* Package */
95 + E1000001 /* JomloCollectionView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = JomloCollectionView; path = ..; sourceTree = "<group>"; };
96 +/* End PBXFileReference section */
97 +
98 +/* Begin PBXFrameworksBuildPhase section */
99 + F1000001 /* Frameworks */ = {
100 + isa = PBXFrameworksBuildPhase;
101 + buildActionMask = 2147483647;
102 + files = (
103 + A1000010 /* JomloCollectionView in Frameworks */,
104 + );
105 + runOnlyForDeploymentPostprocessing = 0;
106 + };
107 + F2000001 /* Frameworks */ = {
108 + isa = PBXFrameworksBuildPhase;
109 + buildActionMask = 2147483647;
110 + files = (
111 + A2000010 /* JomloCollectionView in Frameworks */,
112 + );
113 + runOnlyForDeploymentPostprocessing = 0;
114 + };
115 +/* End PBXFrameworksBuildPhase section */
116 +
117 +/* Begin PBXGroup section */
118 + G0000001 /* Root */ = {
119 + isa = PBXGroup;
120 + children = (
121 + G1000001 /* ExampleApp */,
122 + G2000001 /* ExampleApp_tvOS */,
123 + E1000001 /* JomloCollectionView */,
124 + G0000002 /* Products */,
125 + );
126 + sourceTree = "<group>";
127 + };
128 + G0000002 /* Products */ = {
129 + isa = PBXGroup;
130 + children = (
131 + C1000001 /* JomloCV Example.app */,
132 + C2000001 /* JomloCV tvOS.app */,
133 + );
134 + name = Products;
135 + sourceTree = "<group>";
136 + };
137 + G1000001 /* ExampleApp */ = {
138 + isa = PBXGroup;
139 + children = (
140 + G1000010 /* App */,
141 + G1000020 /* Cells */,
142 + G1000030 /* Models */,
143 + G1000040 /* Sections */,
144 + G1000050 /* ViewControllers */,
145 + B1000020 /* Info.plist */,
146 + );
147 + path = ExampleApp;
148 + sourceTree = "<group>";
149 + };
150 + G1000010 /* App */ = {
151 + isa = PBXGroup;
152 + children = (
153 + B1000001 /* AppDelegate.swift */,
154 + B1000002 /* SceneDelegate.swift */,
155 + );
156 + path = App;
157 + sourceTree = "<group>";
158 + };
159 + G1000020 /* Cells */ = {
160 + isa = PBXGroup;
161 + children = (
162 + B1000004 /* HeroCell.swift */,
163 + B1000005 /* PosterCell.swift */,
164 + B1000006 /* CategoryCell.swift */,
165 + B1000007 /* ContinueWatchingCell.swift */,
166 + );
167 + path = Cells;
168 + sourceTree = "<group>";
169 + };
170 + G1000030 /* Models */ = {
171 + isa = PBXGroup;
172 + children = (
173 + B1000008 /* HeroItem.swift */,
174 + B1000009 /* PosterItem.swift */,
175 + B100000A /* CategoryItem.swift */,
176 + B100000B /* ContinueWatchingItem.swift */,
177 + );
178 + path = Models;
179 + sourceTree = "<group>";
180 + };
181 + G1000040 /* Sections */ = {
182 + isa = PBXGroup;
183 + children = (
184 + B100000C /* HeroSection.swift */,
185 + B100000D /* ShelfSection.swift */,
186 + B100000E /* CategoriesGridSection.swift */,
187 + B100000F /* ContinueWatchingSection.swift */,
188 + );
189 + path = Sections;
190 + sourceTree = "<group>";
191 + };
192 + G1000050 /* ViewControllers */ = {
193 + isa = PBXGroup;
194 + children = (
195 + B1000003 /* StreamingHomeViewController.swift */,
196 + B1000011 /* SeeAllViewController.swift */,
197 + );
198 + path = ViewControllers;
199 + sourceTree = "<group>";
200 + };
201 + G2000001 /* ExampleApp_tvOS */ = {
202 + isa = PBXGroup;
203 + children = (
204 + B2000001 /* AppDelegate.swift */,
205 + B200002C /* TVTabBarController.swift */,
206 + B200002D /* TVTabViewControllers.swift */,
207 + B2000010 /* TVStreamingHomeViewController.swift */,
208 + G2000010 /* Models */,
209 + G2000020 /* Cells */,
210 + G2000030 /* Sections */,
211 + G2000050 /* ViewControllers */,
212 + G2000040 /* Views */,
213 + B2000020 /* Info.plist */,
214 + );
215 + path = ExampleApp_tvOS;
216 + sourceTree = "<group>";
217 + };
218 + G2000010 /* Models */ = {
219 + isa = PBXGroup;
220 + children = (
221 + B2000011 /* TVContentItem.swift */,
222 + );
223 + path = Models;
224 + sourceTree = "<group>";
225 + };
226 + G2000020 /* Cells */ = {
227 + isa = PBXGroup;
228 + children = (
229 + B2000012 /* TVHeroCell.swift */,
230 + B200002F /* TVSeeAllCell.swift */,
231 + B2000013 /* TVPosterCell.swift */,
232 + B2000014 /* TVCategoryCell.swift */,
233 + B2000015 /* TVResumeCell.swift */,
234 + );
235 + path = Cells;
236 + sourceTree = "<group>";
237 + };
238 + G2000030 /* Sections */ = {
239 + isa = PBXGroup;
240 + children = (
241 + B2000016 /* TVHeroSection.swift */,
242 + B2000017 /* TVShelfSection.swift */,
243 + B2000018 /* TVResumeSection.swift */,
244 + B2000019 /* TVCategorySection.swift */,
245 + );
246 + path = Sections;
247 + sourceTree = "<group>";
248 + };
249 + G2000050 /* ViewControllers */ = {
250 + isa = PBXGroup;
251 + children = (
252 + B200002E /* TVSeeAllViewController.swift */,
253 + );
254 + path = ViewControllers;
255 + sourceTree = "<group>";
256 + };
257 + G2000040 /* Views */ = {
258 + isa = PBXGroup;
259 + children = (
260 + B200001A /* TVSectionHeaderView.swift */,
261 + B200001B /* TVColorExtension.swift */,
262 + );
263 + path = Views;
264 + sourceTree = "<group>";
265 + };
266 +/* End PBXGroup section */
267 +
268 +/* Begin PBXNativeTarget section */
269 + T1000001 /* JomloCV Example iOS */ = {
270 + isa = PBXNativeTarget;
271 + buildConfigurationList = X1000001 /* Build configuration list for PBXNativeTarget "JomloCV Example iOS" */;
272 + buildPhases = (
273 + S1000001 /* Sources */,
274 + F1000001 /* Frameworks */,
275 + );
276 + buildRules = (
277 + );
278 + dependencies = (
279 + );
280 + name = "JomloCV Example iOS";
281 + packageProductDependencies = (
282 + D1000001 /* JomloCollectionView */,
283 + );
284 + productName = "JomloCV Example";
285 + productReference = C1000001 /* JomloCV Example.app */;
286 + productType = "com.apple.product-type.application";
287 + };
288 + T2000001 /* JomloCV Example tvOS */ = {
289 + isa = PBXNativeTarget;
290 + buildConfigurationList = X2000001 /* Build configuration list for PBXNativeTarget "JomloCV Example tvOS" */;
291 + buildPhases = (
292 + S2000001 /* Sources */,
293 + F2000001 /* Frameworks */,
294 + );
295 + buildRules = (
296 + );
297 + dependencies = (
298 + );
299 + name = "JomloCV Example tvOS";
300 + packageProductDependencies = (
301 + D2000001 /* JomloCollectionView */,
302 + );
303 + productName = "JomloCV tvOS";
304 + productReference = C2000001 /* JomloCV tvOS.app */;
305 + productType = "com.apple.product-type.application";
306 + };
307 +/* End PBXNativeTarget section */
308 +
309 +/* Begin PBXProject section */
310 + P0000001 /* Project object */ = {
311 + isa = PBXProject;
312 + attributes = {
313 + BuildIndependentTargetsInParallel = 1;
314 + LastSwiftUpdateCheck = 1600;
315 + LastUpgradeCheck = 1600;
316 + TargetAttributes = {
317 + T1000001 = {
318 + CreatedOnToolsVersion = 16.0;
319 + };
320 + T2000001 = {
321 + CreatedOnToolsVersion = 16.0;
322 + };
323 + };
324 + };
325 + buildConfigurationList = X0000001 /* Build configuration list for PBXProject "JomloCollectionViewExample" */;
326 + compatibilityVersion = "Xcode 15.0";
327 + developmentRegion = en;
328 + hasScannedForEncodings = 0;
329 + knownRegions = (
330 + en,
331 + Base,
332 + );
333 + mainGroup = G0000001 /* Root */;
334 + packageReferences = (
335 + PR000001 /* XCLocalSwiftPackageReference ".." */,
336 + );
337 + productRefGroup = G0000002 /* Products */;
338 + projectDirPath = "";
339 + projectRoot = "";
340 + targets = (
341 + T1000001 /* JomloCV Example iOS */,
342 + T2000001 /* JomloCV Example tvOS */,
343 + );
344 + };
345 +/* End PBXProject section */
346 +
347 +/* Begin PBXSourcesBuildPhase section */
348 + S1000001 /* Sources */ = {
349 + isa = PBXSourcesBuildPhase;
350 + buildActionMask = 2147483647;
351 + files = (
352 + A1000001 /* AppDelegate.swift in Sources */,
353 + A1000002 /* SceneDelegate.swift in Sources */,
354 + A1000003 /* StreamingHomeViewController.swift in Sources */,
355 + A1000011 /* SeeAllViewController.swift in Sources */,
356 + A1000004 /* HeroCell.swift in Sources */,
357 + A1000005 /* PosterCell.swift in Sources */,
358 + A1000006 /* CategoryCell.swift in Sources */,
359 + A1000007 /* ContinueWatchingCell.swift in Sources */,
360 + A1000008 /* HeroItem.swift in Sources */,
361 + A1000009 /* PosterItem.swift in Sources */,
362 + A100000A /* CategoryItem.swift in Sources */,
363 + A100000B /* ContinueWatchingItem.swift in Sources */,
364 + A100000C /* HeroSection.swift in Sources */,
365 + A100000D /* ShelfSection.swift in Sources */,
366 + A100000E /* CategoriesGridSection.swift in Sources */,
367 + A100000F /* ContinueWatchingSection.swift in Sources */,
368 + );
369 + runOnlyForDeploymentPostprocessing = 0;
370 + };
371 + S2000001 /* Sources */ = {
372 + isa = PBXSourcesBuildPhase;
373 + buildActionMask = 2147483647;
374 + files = (
375 + A2000001 /* AppDelegate.swift in Sources */,
376 + A200002C /* TVTabBarController.swift in Sources */,
377 + A200002E /* TVSeeAllViewController.swift in Sources */,
378 + A200002D /* TVTabViewControllers.swift in Sources */,
379 + A2000020 /* TVStreamingHomeViewController.swift in Sources */,
380 + A2000021 /* TVContentItem.swift in Sources */,
381 + A2000022 /* TVHeroCell.swift in Sources */,
382 + A200002F /* TVSeeAllCell.swift in Sources */,
383 + A2000023 /* TVPosterCell.swift in Sources */,
384 + A2000024 /* TVCategoryCell.swift in Sources */,
385 + A2000025 /* TVResumeCell.swift in Sources */,
386 + A2000026 /* TVHeroSection.swift in Sources */,
387 + A2000027 /* TVShelfSection.swift in Sources */,
388 + A2000028 /* TVResumeSection.swift in Sources */,
389 + A2000029 /* TVCategorySection.swift in Sources */,
390 + A200002A /* TVSectionHeaderView.swift in Sources */,
391 + A200002B /* TVColorExtension.swift in Sources */,
392 + );
393 + runOnlyForDeploymentPostprocessing = 0;
394 + };
395 +/* End PBXSourcesBuildPhase section */
396 +
397 +/* Begin XCBuildConfiguration section */
398 + /* Project Debug */
399 + XC000001 /* Debug */ = {
400 + isa = XCBuildConfiguration;
401 + buildSettings = {
402 + ALWAYS_SEARCH_USER_PATHS = NO;
403 + CLANG_ANALYZER_NONNULL = YES;
404 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
405 + CLANG_ENABLE_MODULES = YES;
406 + CLANG_ENABLE_OBJC_ARC = YES;
407 + COPY_PHASE_STRIP = NO;
408 + DEBUG_INFORMATION_FORMAT = dwarf;
409 + ENABLE_STRICT_OBJC_MSGSEND = YES;
410 + ENABLE_TESTABILITY = YES;
411 + GCC_DYNAMIC_NO_PIC = NO;
412 + GCC_OPTIMIZATION_LEVEL = 0;
413 + GCC_PREPROCESSOR_DEFINITIONS = (
414 + "DEBUG=1",
415 + "$(inherited)",
416 + );
417 + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
418 + ONLY_ACTIVE_ARCH = YES;
419 + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
420 + SWIFT_OPTIMIZATION_LEVEL = "-Onone";
421 + SWIFT_VERSION = 6.0;
422 + SWIFT_STRICT_CONCURRENCY = complete;
423 + };
424 + name = Debug;
425 + };
426 + /* Project Release */
427 + XC000002 /* Release */ = {
428 + isa = XCBuildConfiguration;
429 + buildSettings = {
430 + ALWAYS_SEARCH_USER_PATHS = NO;
431 + CLANG_ANALYZER_NONNULL = YES;
432 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
433 + CLANG_ENABLE_MODULES = YES;
434 + CLANG_ENABLE_OBJC_ARC = YES;
435 + COPY_PHASE_STRIP = NO;
436 + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
437 + ENABLE_NS_ASSERTIONS = NO;
438 + ENABLE_STRICT_OBJC_MSGSEND = YES;
439 + GCC_OPTIMIZATION_LEVEL = s;
440 + MTL_ENABLE_DEBUG_INFO = NO;
441 + SWIFT_COMPILATION_MODE = wholemodule;
442 + SWIFT_VERSION = 6.0;
443 + SWIFT_STRICT_CONCURRENCY = complete;
444 + };
445 + name = Release;
446 + };
447 + /* iOS Target Debug */
448 + XC100001 /* Debug */ = {
449 + isa = XCBuildConfiguration;
450 + buildSettings = {
451 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
452 + CODE_SIGN_STYLE = Automatic;
453 + CURRENT_PROJECT_VERSION = 1;
454 + GENERATE_INFOPLIST_FILE = NO;
455 + INFOPLIST_FILE = ExampleApp/Info.plist;
456 + IPHONEOS_DEPLOYMENT_TARGET = 17.0;
457 + LD_RUNPATH_SEARCH_PATHS = (
458 + "$(inherited)",
459 + "@executable_path/Frameworks",
460 + );
461 + MARKETING_VERSION = 1.0;
462 + PRODUCT_BUNDLE_IDENTIFIER = com.setoelkahfi.JomloCollectionView.Example;
463 + PRODUCT_NAME = "JomloCV Example";
464 + SDKROOT = iphoneos;
465 + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
466 + SUPPORTS_MACCATALYST = NO;
467 + SWIFT_EMIT_LOC_STRINGS = YES;
468 + TARGETED_DEVICE_FAMILY = "1,2";
469 + };
470 + name = Debug;
471 + };
472 + /* iOS Target Release */
473 + XC100002 /* Release */ = {
474 + isa = XCBuildConfiguration;
475 + buildSettings = {
476 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
477 + CODE_SIGN_STYLE = Automatic;
478 + CURRENT_PROJECT_VERSION = 1;
479 + GENERATE_INFOPLIST_FILE = NO;
480 + INFOPLIST_FILE = ExampleApp/Info.plist;
481 + IPHONEOS_DEPLOYMENT_TARGET = 17.0;
482 + LD_RUNPATH_SEARCH_PATHS = (
483 + "$(inherited)",
484 + "@executable_path/Frameworks",
485 + );
486 + MARKETING_VERSION = 1.0;
487 + PRODUCT_BUNDLE_IDENTIFIER = com.setoelkahfi.JomloCollectionView.Example;
488 + PRODUCT_NAME = "JomloCV Example";
489 + SDKROOT = iphoneos;
490 + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
491 + SUPPORTS_MACCATALYST = NO;
492 + SWIFT_EMIT_LOC_STRINGS = YES;
493 + TARGETED_DEVICE_FAMILY = "1,2";
494 + VALIDATE_PRODUCT = YES;
495 + };
496 + name = Release;
497 + };
498 + /* tvOS Target Debug */
499 + XC200001 /* Debug */ = {
500 + isa = XCBuildConfiguration;
501 + buildSettings = {
502 + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
503 + CODE_SIGN_STYLE = Automatic;
504 + CURRENT_PROJECT_VERSION = 1;
505 + GENERATE_INFOPLIST_FILE = NO;
506 + INFOPLIST_FILE = ExampleApp_tvOS/Info.plist;
507 + LD_RUNPATH_SEARCH_PATHS = (
508 + "$(inherited)",
509 + "@executable_path/Frameworks",
510 + );
511 + MARKETING_VERSION = 1.0;
512 + PRODUCT_BUNDLE_IDENTIFIER = com.setoelkahfi.JomloCollectionView.Example.tvOS;
513 + PRODUCT_NAME = "JomloCV tvOS";
514 + SDKROOT = appletvos;
515 + SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
516 + SWIFT_EMIT_LOC_STRINGS = YES;
517 + TARGETED_DEVICE_FAMILY = 3;
518 + TVOS_DEPLOYMENT_TARGET = 17.0;
519 + };
520 + name = Debug;
521 + };
522 + /* tvOS Target Release */
523 + XC200002 /* Release */ = {
524 + isa = XCBuildConfiguration;
525 + buildSettings = {
526 + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
527 + CODE_SIGN_STYLE = Automatic;
528 + CURRENT_PROJECT_VERSION = 1;
529 + GENERATE_INFOPLIST_FILE = NO;
530 + INFOPLIST_FILE = ExampleApp_tvOS/Info.plist;
531 + LD_RUNPATH_SEARCH_PATHS = (
532 + "$(inherited)",
533 + "@executable_path/Frameworks",
534 + );
535 + MARKETING_VERSION = 1.0;
536 + PRODUCT_BUNDLE_IDENTIFIER = com.setoelkahfi.JomloCollectionView.Example.tvOS;
537 + PRODUCT_NAME = "JomloCV tvOS";
538 + SDKROOT = appletvos;
539 + SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
540 + SWIFT_EMIT_LOC_STRINGS = YES;
541 + TARGETED_DEVICE_FAMILY = 3;
542 + TVOS_DEPLOYMENT_TARGET = 17.0;
543 + VALIDATE_PRODUCT = YES;
544 + };
545 + name = Release;
546 + };
547 +/* End XCBuildConfiguration section */
548 +
549 +/* Begin XCConfigurationList section */
550 + X0000001 /* Build configuration list for PBXProject "JomloCollectionViewExample" */ = {
551 + isa = XCConfigurationList;
552 + buildConfigurations = (
553 + XC000001 /* Debug */,
554 + XC000002 /* Release */,
555 + );
556 + defaultConfigurationIsVisible = 0;
557 + defaultConfigurationName = Release;
558 + };
559 + X1000001 /* Build configuration list for PBXNativeTarget "JomloCV Example iOS" */ = {
560 + isa = XCConfigurationList;
561 + buildConfigurations = (
562 + XC100001 /* Debug */,
563 + XC100002 /* Release */,
564 + );
565 + defaultConfigurationIsVisible = 0;
566 + defaultConfigurationName = Release;
567 + };
568 + X2000001 /* Build configuration list for PBXNativeTarget "JomloCV Example tvOS" */ = {
569 + isa = XCConfigurationList;
570 + buildConfigurations = (
571 + XC200001 /* Debug */,
572 + XC200002 /* Release */,
573 + );
574 + defaultConfigurationIsVisible = 0;
575 + defaultConfigurationName = Release;
576 + };
577 +/* End XCConfigurationList section */
578 +
579 +/* Begin XCLocalSwiftPackageReference section */
580 + PR000001 /* XCLocalSwiftPackageReference ".." */ = {
581 + isa = XCLocalSwiftPackageReference;
582 + relativePath = ..;
583 + };
584 +/* End XCLocalSwiftPackageReference section */
585 +
586 +/* Begin XCSwiftPackageProductDependency section */
587 + D1000001 /* JomloCollectionView */ = {
588 + isa = XCSwiftPackageProductDependency;
589 + package = PR000001 /* XCLocalSwiftPackageReference ".." */;
590 + productName = JomloCollectionView;
591 + };
592 + D2000001 /* JomloCollectionView */ = {
593 + isa = XCSwiftPackageProductDependency;
594 + package = PR000001 /* XCLocalSwiftPackageReference ".." */;
595 + productName = JomloCollectionView;
596 + };
597 +/* End XCSwiftPackageProductDependency section */
598 + };
599 + rootObject = P0000001 /* Project object */;
600 +}
Example/JomloCollectionViewExample.xcodeproj/xcshareddata/xcschemes/JomloCV Example iOS.xcscheme new
+77
@@ -0,0 +1,77 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<Scheme
3 + LastUpgradeVersion = "1600"
4 + version = "1.7">
5 + <BuildAction
6 + parallelizeBuildables = "YES"
7 + buildImplicitDependencies = "YES">
8 + <BuildActionEntries>
9 + <BuildActionEntry
10 + buildForTesting = "YES"
11 + buildForRunning = "YES"
12 + buildForProfiling = "YES"
13 + buildForArchiving = "YES"
14 + buildForAnalyzing = "YES">
15 + <BuildableReference
16 + BuildableIdentifier = "primary"
17 + BlueprintIdentifier = "T1000001"
18 + BuildableName = "JomloCV Example.app"
19 + BlueprintName = "JomloCV Example iOS"
20 + ReferencedContainer = "container:JomloCollectionViewExample.xcodeproj">
21 + </BuildableReference>
22 + </BuildActionEntry>
23 + </BuildActionEntries>
24 + </BuildAction>
25 + <TestAction
26 + buildConfiguration = "Debug"
27 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29 + shouldUseLaunchSchemeArgsEnv = "YES"
30 + shouldAutocreateTestPlan = "YES">
31 + </TestAction>
32 + <LaunchAction
33 + buildConfiguration = "Debug"
34 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
35 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
36 + launchStyle = "0"
37 + useCustomWorkingDirectory = "NO"
38 + ignoresPersistentStateOnLaunch = "NO"
39 + debugDocumentVersioning = "YES"
40 + debugServiceExtension = "internal"
41 + allowLocationSimulation = "YES">
42 + <BuildableProductRunnable
43 + runnableDebuggingMode = "0">
44 + <BuildableReference
45 + BuildableIdentifier = "primary"
46 + BlueprintIdentifier = "T1000001"
47 + BuildableName = "JomloCV Example.app"
48 + BlueprintName = "JomloCV Example iOS"
49 + ReferencedContainer = "container:JomloCollectionViewExample.xcodeproj">
50 + </BuildableReference>
51 + </BuildableProductRunnable>
52 + </LaunchAction>
53 + <ProfileAction
54 + buildConfiguration = "Release"
55 + shouldUseLaunchSchemeArgsEnv = "YES"
56 + savedToolIdentifier = ""
57 + useCustomWorkingDirectory = "NO"
58 + debugDocumentVersioning = "YES">
59 + <BuildableProductRunnable
60 + runnableDebuggingMode = "0">
61 + <BuildableReference
62 + BuildableIdentifier = "primary"
63 + BlueprintIdentifier = "T1000001"
64 + BuildableName = "JomloCV Example.app"
65 + BlueprintName = "JomloCV Example iOS"
66 + ReferencedContainer = "container:JomloCollectionViewExample.xcodeproj">
67 + </BuildableReference>
68 + </BuildableProductRunnable>
69 + </ProfileAction>
70 + <AnalyzeAction
71 + buildConfiguration = "Debug">
72 + </AnalyzeAction>
73 + <ArchiveAction
74 + buildConfiguration = "Release"
75 + revealArchiveInOrganizer = "YES">
76 + </ArchiveAction>
77 +</Scheme>
Example/JomloCollectionViewExample.xcodeproj/xcshareddata/xcschemes/JomloCV Example tvOS.xcscheme new
+77
@@ -0,0 +1,77 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<Scheme
3 + LastUpgradeVersion = "1600"
4 + version = "1.7">
5 + <BuildAction
6 + parallelizeBuildables = "YES"
7 + buildImplicitDependencies = "YES">
8 + <BuildActionEntries>
9 + <BuildActionEntry
10 + buildForTesting = "YES"
11 + buildForRunning = "YES"
12 + buildForProfiling = "YES"
13 + buildForArchiving = "YES"
14 + buildForAnalyzing = "YES">
15 + <BuildableReference
16 + BuildableIdentifier = "primary"
17 + BlueprintIdentifier = "T2000001"
18 + BuildableName = "JomloCV tvOS.app"
19 + BlueprintName = "JomloCV Example tvOS"
20 + ReferencedContainer = "container:JomloCollectionViewExample.xcodeproj">
21 + </BuildableReference>
22 + </BuildActionEntry>
23 + </BuildActionEntries>
24 + </BuildAction>
25 + <TestAction
26 + buildConfiguration = "Debug"
27 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29 + shouldUseLaunchSchemeArgsEnv = "YES"
30 + shouldAutocreateTestPlan = "YES">
31 + </TestAction>
32 + <LaunchAction
33 + buildConfiguration = "Debug"
34 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
35 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
36 + launchStyle = "0"
37 + useCustomWorkingDirectory = "NO"
38 + ignoresPersistentStateOnLaunch = "NO"
39 + debugDocumentVersioning = "YES"
40 + debugServiceExtension = "internal"
41 + allowLocationSimulation = "YES">
42 + <BuildableProductRunnable
43 + runnableDebuggingMode = "0">
44 + <BuildableReference
45 + BuildableIdentifier = "primary"
46 + BlueprintIdentifier = "T2000001"
47 + BuildableName = "JomloCV tvOS.app"
48 + BlueprintName = "JomloCV Example tvOS"
49 + ReferencedContainer = "container:JomloCollectionViewExample.xcodeproj">
50 + </BuildableReference>
51 + </BuildableProductRunnable>
52 + </LaunchAction>
53 + <ProfileAction
54 + buildConfiguration = "Release"
55 + shouldUseLaunchSchemeArgsEnv = "YES"
56 + savedToolIdentifier = ""
57 + useCustomWorkingDirectory = "NO"
58 + debugDocumentVersioning = "YES">
59 + <BuildableProductRunnable
60 + runnableDebuggingMode = "0">
61 + <BuildableReference
62 + BuildableIdentifier = "primary"
63 + BlueprintIdentifier = "T2000001"
64 + BuildableName = "JomloCV tvOS.app"
65 + BlueprintName = "JomloCV Example tvOS"
66 + ReferencedContainer = "container:JomloCollectionViewExample.xcodeproj">
67 + </BuildableReference>
68 + </BuildableProductRunnable>
69 + </ProfileAction>
70 + <AnalyzeAction
71 + buildConfiguration = "Debug">
72 + </AnalyzeAction>
73 + <ArchiveAction
74 + buildConfiguration = "Release"
75 + revealArchiveInOrganizer = "YES">
76 + </ArchiveAction>
77 +</Scheme>
JomloCollectionView.podspec new
+32
@@ -0,0 +1,32 @@
1 +Pod::Spec.new do |s|
2 + s.name = 'JomloCollectionView'
3 + s.version = '1.0.0'
4 + s.swift_version = '6.0'
5 + s.summary = 'A declarative, section-driven UICollectionView compositional layout engine.'
6 +
7 + s.description = <<~DESC
8 + JomloCollectionView is a protocol-oriented UICollectionView compositional layout engine
9 + for iOS and tvOS. Build complex, streaming-service-style collection views with minimal
10 + boilerplate. Features include built-in layout presets (hero, shelf, grid, list), diffable
11 + data source integration, self-configuring cells, declarative animations, and full Swift 6
12 + strict concurrency support.
13 + DESC
14 +
15 + s.homepage = 'https://github.com/setoelkahfi/JomloCollectionView'
16 + s.license = { type: 'MIT', file: 'LICENSE' }
17 + s.author = { 'Seto Elkahfi' => 'setoelkahfi@gmail.com' }
18 + s.source = { git: 'https://github.com/setoelkahfi/JomloCollectionView.git', tag: s.version.to_s }
19 + s.social_media_url = 'https://twitter.com/setoelkahfi'
20 +
21 + s.ios.deployment_target = '17.0'
22 + s.tvos.deployment_target = '17.0'
23 +
24 + s.source_files = 'Sources/JomloCollectionView/**/*.swift'
25 +
26 + s.frameworks = 'UIKit'
27 +
28 + s.pod_target_xcconfig = {
29 + 'SWIFT_VERSION' => '6.0',
30 + 'SWIFT_STRICT_CONCURRENCY' => 'complete'
31 + }
32 +end
LICENSE new
+21
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Seto Elkahfi
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
Package.swift new
+33
@@ -0,0 +1,33 @@
1 +// swift-tools-version: 6.0
2 +// The swift-tools-version declares the minimum version of Swift required to build this package.
3 +
4 +import PackageDescription
5 +
6 +let package = Package(
7 + name: "JomloCollectionView",
8 + platforms: [
9 + .iOS(.v17),
10 + .tvOS(.v17),
11 + ],
12 + products: [
13 + .library(
14 + name: "JomloCollectionView",
15 + targets: ["JomloCollectionView"]
16 + )
17 + ],
18 + targets: [
19 + .target(
20 + name: "JomloCollectionView",
21 + swiftSettings: [
22 + .swiftLanguageMode(.v6)
23 + ]
24 + ),
25 + .testTarget(
26 + name: "JomloCollectionViewTests",
27 + dependencies: ["JomloCollectionView"],
28 + swiftSettings: [
29 + .swiftLanguageMode(.v6)
30 + ]
31 + ),
32 + ]
33 +)
README.md new
+255
@@ -0,0 +1,255 @@
1 +# JomloCollectionView
2 +
3 +[![Swift 6.0](https://img.shields.io/badge/Swift-6.0-orange.svg)](https://swift.org)
4 +[![Platforms](https://img.shields.io/badge/Platforms-iOS%2017%20%7C%20tvOS%2017-blue.svg)](https://developer.apple.com)
5 +[![License](https://img.shields.io/badge/License-MIT-lightgrey.svg)](LICENSE)
6 +
7 +JomloCollectionView is a small UIKit package for building section based `UICollectionView` screens on iOS and tvOS.
8 +
9 +It is meant for screens made from mixed sections: a hero banner, a few horizontal shelves, a grid, maybe a list. Each section owns its items, layout, cell configuration, and selection behavior. The view controller stitches those sections into one compositional layout and keeps the diffable data source out of your app code.
10 +
11 +If you have used [JomloTableView](https://github.com/setoelkahfi/JomloTableView), this is the same idea moved to `UICollectionView`, with compositional layouts and Swift 6 concurrency rules.
12 +
13 +## Screenshots
14 +
15 +<p>
16 + <img src="assets/Simulator%20Screenshot%20-%20iPhone%2017%20Pro%20-%202026-05-18%20at%2019.41.17.png" alt="JomloCollectionView example app home screen" width="260">
17 + <img src="assets/Simulator%20Screenshot%20-%20iPhone%2017%20Pro%20-%202026-05-18%20at%2019.41.25.png" alt="JomloCollectionView example app shelves and categories" width="260">
18 + <img src="assets/Simulator%20Screenshot%20-%20iPhone%2017%20Pro%20-%202026-05-18%20at%2019.41.37.png" alt="JomloCollectionView example app see all screen" width="260">
19 +</p>
20 +
21 +## What it gives you
22 +
23 +- Section objects that define their own layout, items, cells, and selection handling
24 +- `UICollectionViewCompositionalLayout` support without one large layout switch in the view controller
25 +- A diffable data source managed by `JomloCollectionViewController`
26 +- Layout helpers for hero, shelf, grid, list, and banner sections
27 +- Self configuring cells through `JomloSelfConfiguringCell`
28 +- Optional cell appearance animations
29 +- Swift 6 language mode with `Sendable` and `@MainActor` annotations
30 +- UIKit only, with no external package dependencies
31 +
32 +## Requirements
33 +
34 +- iOS 17.0 or later
35 +- tvOS 17.0 or later
36 +- Swift 6.0 or later
37 +- Xcode 16.0 or later
38 +
39 +## Installation
40 +
41 +### Swift Package Manager
42 +
43 +Add the package to `Package.swift`:
44 +
45 +```swift
46 +dependencies: [
47 + .package(url: "https://github.com/setoelkahfi/JomloCollectionView", from: "1.0.0")
48 +]
49 +```
50 +
51 +In Xcode, use `File > Add Package Dependencies` and paste the repository URL.
52 +
53 +### CocoaPods
54 +
55 +Add this to your `Podfile`:
56 +
57 +```ruby
58 +pod 'JomloCollectionView', '~> 1.0'
59 +```
60 +
61 +Then run:
62 +
63 +```bash
64 +pod install
65 +```
66 +
67 +## Quick start
68 +
69 +Define an item:
70 +
71 +```swift
72 +struct MovieItem: JomloItem {
73 + let id: String
74 + let title: String
75 + let posterColor: String
76 +
77 + nonisolated var itemIdentifier: String { id }
78 +}
79 +```
80 +
81 +Create a cell that knows how to render that item:
82 +
83 +```swift
84 +final class MovieCell: UICollectionViewCell, JomloSelfConfiguringCell {
85 + private let titleLabel = UILabel()
86 +
87 + func configure(with item: MovieItem) {
88 + titleLabel.text = item.title
89 + }
90 +}
91 +```
92 +
93 +Put the layout and cell wiring in a section:
94 +
95 +```swift
96 +@MainActor
97 +final class MoviesShelfSection: JomloSection {
98 + let identifier = JomloSectionID("movies")
99 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
100 +
101 + private let movies: [MovieItem]
102 +
103 + init(movies: [MovieItem]) {
104 + self.movies = movies
105 + }
106 +
107 + func items() -> [any JomloItem] {
108 + movies
109 + }
110 +
111 + func layoutSection(
112 + environment: NSCollectionLayoutEnvironment,
113 + sectionIndex: Int
114 + ) -> NSCollectionLayoutSection {
115 + JomloLayoutFactory.horizontalShelf()
116 + }
117 +
118 + func cell(
119 + for item: any JomloItem,
120 + in collectionView: UICollectionView,
121 + at indexPath: IndexPath
122 + ) -> UICollectionViewCell {
123 + guard let movie = item as? MovieItem else {
124 + return UICollectionViewCell()
125 + }
126 +
127 + return collectionView.dequeueJomloCell(MovieCell.self, for: indexPath, item: movie)
128 + }
129 +}
130 +```
131 +
132 +Then build the screen from sections:
133 +
134 +```swift
135 +final class MoviesViewController: JomloCollectionViewController {
136 + override func viewDidLoad() {
137 + super.viewDidLoad()
138 + collectionView.registerJomloCell(MovieCell.self)
139 + }
140 +}
141 +
142 +let viewController = MoviesViewController(sections: [
143 + heroSection,
144 + continueWatchingSection,
145 + moviesShelfSection,
146 + categoriesGridSection,
147 +])
148 +```
149 +
150 +`JomloCollectionViewController` owns the collection view, builds the compositional layout, applies snapshots, and forwards selection back to the section.
151 +
152 +## How the pieces fit
153 +
154 +```text
155 +JomloCollectionViewController
156 + - UICollectionViewCompositionalLayout
157 + - UICollectionViewDiffableDataSource
158 +
159 +Sections
160 + - provide items
161 + - build layout sections
162 + - dequeue and configure cells
163 + - handle selection
164 +
165 +Items
166 + - provide stable identifiers for diffing
167 + - carry the data needed by cells
168 +```
169 +
170 +## Core types
171 +
172 +| Type | What it does |
173 +|------|--------------|
174 +| `JomloSection` | Defines one section's items, layout, cells, supplementary views, selection, and reload callback |
175 +| `JomloItem` | A `Hashable` and `Sendable` view model with a stable `itemIdentifier` |
176 +| `JomloSelfConfiguringCell` | Lets a cell configure itself from one item type |
177 +| `JomloCollectionViewController` | Hosts the collection view, compositional layout, and diffable data source |
178 +| `JomloLayoutFactory` | Creates common compositional layout sections |
179 +| `JomloAnimator` | Runs optional appearance animations as cells are displayed |
180 +| `JomloSectionHeaderView` | A reusable section header with a title and action button |
181 +
182 +## Layout presets
183 +
184 +| Preset | Use it for |
185 +|--------|------------|
186 +| `.hero()` | A wide featured item with optional paging |
187 +| `.horizontalShelf()` | A horizontally scrolling row of cards |
188 +| `.grid(columns:)` | A multi column grid |
189 +| `.list()` | A single column vertical list |
190 +| `.banner()` | A full width banner section |
191 +
192 +## Animations
193 +
194 +| Animation | Effect |
195 +|-----------|--------|
196 +| `.fadeIn()` | Fades cells in with a staggered delay |
197 +| `.moveUpWithFade()` | Moves cells up while fading them in |
198 +| `.scaleUp()` | Scales cells from a smaller size while fading them in |
199 +
200 +You can set an animation per section with `displayAnimation`, or set a shared `JomloAnimator` on the view controller.
201 +
202 +## Example app
203 +
204 +The `Example/` directory contains an Xcode project with iOS and tvOS targets. It builds a streaming style home screen with:
205 +
206 +- A featured hero section
207 +- Continue watching shelves with progress indicators
208 +- Poster shelves
209 +- A categories grid
210 +- Section headers with a "See All" action
211 +
212 +To run it:
213 +
214 +```bash
215 +cd Example
216 +open JomloCollectionViewExample.xcodeproj
217 +```
218 +
219 +Then choose one of these schemes:
220 +
221 +- `JomloCV Example iOS` for an iPhone or iPad simulator
222 +- `JomloCV Example tvOS` for an Apple TV simulator
223 +
224 +Most cells, models, sections, and view controllers are shared between the targets. The platform specific app entry points live in their own target folders.
225 +
226 +## Moving from JomloTableView
227 +
228 +| JomloTableView | JomloCollectionView |
229 +|----------------|---------------------|
230 +| `JomloTableViewSection` | `JomloSection` |
231 +| `JomloTableViewRow` | `JomloItem` |
232 +| `JomloTableViewCell` | `JomloSelfConfiguringCell` |
233 +| `JomloTableView` | `JomloCollectionViewController` |
234 +| `populateView(cell:)` | `configure(with:)` |
235 +| `addSection(section:)` | `setSections([...])` or `appendSection(_:)` |
236 +| Class inheritance | Protocol composition |
237 +| `UITableView` | `UICollectionViewCompositionalLayout` |
238 +
239 +## Design notes
240 +
241 +The package works best when each section is treated as a small, reusable unit. A section should know how to lay itself out and how to render its items. The screen should only decide which sections appear and in what order.
242 +
243 +That keeps the view controller thin, and it makes common product screens easier to change. You can reuse a shelf section with different data, swap a grid from two columns to four, or map server returned section types into local section objects.
244 +
245 +## Author
246 +
247 +Seto Elkahfi - [@setoelkahfi](https://github.com/setoelkahfi)
248 +
249 +## License
250 +
251 +JomloCollectionView is available under the MIT license. See [LICENSE](LICENSE) for details.
252 +
253 +## Copyright
254 +
255 +2026 Onde Inference (Splitfire AB)
Sources/JomloCollectionView/Animation/JomloAnimator.swift new
+132
@@ -0,0 +1,132 @@
1 +//
2 +// JomloAnimator.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// Provides declarative cell appearance animations for collection view cells.
12 +///
13 +/// Animations are applied as cells appear on screen. Once all visible cells
14 +/// have been animated, the animator stops to avoid re-animating on scroll.
15 +@MainActor
16 +public final class JomloAnimator: Sendable {
17 +
18 + /// A closure that animates a cell as it appears.
19 + ///
20 + /// - Parameters:
21 + /// - view: The cell view to animate.
22 + /// - indexPath: The index path of the cell.
23 + /// - collectionView: The parent collection view.
24 + public typealias Animation = @MainActor @Sendable (UIView, IndexPath, UICollectionView) -> Void
25 +
26 + private let animation: Animation
27 + private var hasAnimatedAllCells = false
28 +
29 + /// Creates an animator with a custom animation closure.
30 + ///
31 + /// - Parameter animation: The animation to apply to each cell.
32 + public init(animation: @escaping Animation) {
33 + self.animation = animation
34 + }
35 +
36 + /// Animates a cell at the given index path.
37 + ///
38 + /// Stops animating after all initially visible cells have been shown.
39 + public func animate(view: UIView, at indexPath: IndexPath, in collectionView: UICollectionView)
40 + {
41 + guard !hasAnimatedAllCells else { return }
42 +
43 + animation(view, indexPath, collectionView)
44 +
45 + hasAnimatedAllCells =
46 + collectionView.visibleCells.last == collectionView.cellForItem(at: indexPath)
47 + }
48 +
49 + /// Resets the animator so it will animate cells again.
50 + public func reset() {
51 + hasAnimatedAllCells = false
52 + }
53 +}
54 +
55 +// MARK: - Built-in Animations
56 +
57 +extension JomloAnimator {
58 +
59 + /// A fade-in animation with staggered delay per row.
60 + ///
61 + /// - Parameters:
62 + /// - duration: The duration of each cell's fade animation.
63 + /// - delayFactor: Multiplied by the row index to stagger animations.
64 + /// - Returns: An animation closure.
65 + public static func fadeIn(duration: TimeInterval = 0.4, delayFactor: Double = 0.05) -> Animation
66 + {
67 + { view, indexPath, _ in
68 + view.alpha = 0
69 + UIView.animate(
70 + withDuration: duration,
71 + delay: delayFactor * Double(indexPath.item),
72 + animations: {
73 + view.alpha = 1
74 + }
75 + )
76 + }
77 + }
78 +
79 + /// A move-up-with-fade animation.
80 + ///
81 + /// - Parameters:
82 + /// - offset: How far down the cell starts before moving up.
83 + /// - duration: The animation duration.
84 + /// - delayFactor: Multiplied by the row index to stagger animations.
85 + /// - Returns: An animation closure.
86 + public static func moveUpWithFade(
87 + offset: CGFloat = 30,
88 + duration: TimeInterval = 0.5,
89 + delayFactor: Double = 0.05
90 + ) -> Animation {
91 + { view, indexPath, _ in
92 + view.transform = CGAffineTransform(translationX: 0, y: offset)
93 + view.alpha = 0
94 + UIView.animate(
95 + withDuration: duration,
96 + delay: delayFactor * Double(indexPath.item),
97 + options: [.curveEaseInOut],
98 + animations: {
99 + view.transform = .identity
100 + view.alpha = 1
101 + }
102 + )
103 + }
104 + }
105 +
106 + /// A scale-up animation from a smaller size.
107 + ///
108 + /// - Parameters:
109 + /// - initialScale: The starting scale (e.g., 0.8).
110 + /// - duration: The animation duration.
111 + /// - delayFactor: Multiplied by the row index to stagger animations.
112 + /// - Returns: An animation closure.
113 + public static func scaleUp(
114 + initialScale: CGFloat = 0.85,
115 + duration: TimeInterval = 0.4,
116 + delayFactor: Double = 0.05
117 + ) -> Animation {
118 + { view, indexPath, _ in
119 + view.transform = CGAffineTransform(scaleX: initialScale, y: initialScale)
120 + view.alpha = 0
121 + UIView.animate(
122 + withDuration: duration,
123 + delay: delayFactor * Double(indexPath.item),
124 + options: [.curveEaseOut],
125 + animations: {
126 + view.transform = .identity
127 + view.alpha = 1
128 + }
129 + )
130 + }
131 + }
132 +}
Sources/JomloCollectionView/Helpers/JomloSectionHeaderView.swift new
+101
@@ -0,0 +1,101 @@
1 +//
2 +// JomloSectionHeaderView.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A reusable section header view with a title label and optional action button.
12 +///
13 +/// Register this with your collection view for section headers:
14 +/// ```swift
15 +/// collectionView.register(
16 +/// JomloSectionHeaderView.self,
17 +/// forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
18 +/// withReuseIdentifier: JomloSectionHeaderView.reuseIdentifier
19 +/// )
20 +/// ```
21 +@MainActor
22 +open class JomloSectionHeaderView: UICollectionReusableView {
23 +
24 + /// The reuse identifier for this header view.
25 + public static let reuseIdentifier = "JomloSectionHeaderView"
26 +
27 + /// The title label.
28 + public let titleLabel: UILabel = {
29 + let label = UILabel()
30 + label.font = .preferredFont(forTextStyle: .headline)
31 + label.adjustsFontForContentSizeCategory = true
32 + label.accessibilityTraits = .header
33 + return label
34 + }()
35 +
36 + /// An optional action button (e.g., "See All").
37 + public let actionButton: UIButton = {
38 + let button = UIButton(type: .system)
39 + button.titleLabel?.font = .preferredFont(forTextStyle: .subheadline)
40 + button.titleLabel?.adjustsFontForContentSizeCategory = true
41 + button.isHidden = true
42 + return button
43 + }()
44 +
45 + /// Callback when the action button is tapped.
46 + public var onActionTapped: (@MainActor @Sendable () -> Void)?
47 +
48 + public override init(frame: CGRect) {
49 + super.init(frame: frame)
50 + setupViews()
51 + }
52 +
53 + @available(*, unavailable)
54 + public required init?(coder: NSCoder) {
55 + fatalError("init(coder:) is not supported.")
56 + }
57 +
58 + /// Configures the header with a title and optional action.
59 + ///
60 + /// - Parameters:
61 + /// - title: The section title.
62 + /// - actionTitle: Optional action button title. Pass nil to hide the button.
63 + /// - action: Callback when the action button is tapped.
64 + public func configure(
65 + title: String,
66 + actionTitle: String? = nil,
67 + action: (@MainActor @Sendable () -> Void)? = nil
68 + ) {
69 + titleLabel.text = title
70 + if let actionTitle {
71 + actionButton.setTitle(actionTitle, for: .normal)
72 + actionButton.isHidden = false
73 + onActionTapped = action
74 + } else {
75 + actionButton.isHidden = true
76 + onActionTapped = nil
77 + }
78 + }
79 +
80 + private func setupViews() {
81 + let stack = UIStackView(arrangedSubviews: [titleLabel, actionButton])
82 + stack.axis = .horizontal
83 + stack.alignment = .center
84 + stack.distribution = .fill
85 + stack.translatesAutoresizingMaskIntoConstraints = false
86 +
87 + addSubview(stack)
88 + NSLayoutConstraint.activate([
89 + stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
90 + stack.leadingAnchor.constraint(equalTo: leadingAnchor),
91 + stack.trailingAnchor.constraint(equalTo: trailingAnchor),
92 + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
93 + ])
94 +
95 + actionButton.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside)
96 + }
97 +
98 + @objc private func actionButtonTapped() {
99 + onActionTapped?()
100 + }
101 +}
Sources/JomloCollectionView/Helpers/UICollectionView+JomloRegistration.swift new
+40
@@ -0,0 +1,40 @@
1 +//
2 +// UICollectionView+JomloRegistration.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +extension UICollectionView {
12 +
13 + /// Registers a `JomloSelfConfiguringCell` for use with the collection view.
14 + ///
15 + /// - Parameter cellType: The cell class to register.
16 + public func registerJomloCell<T: JomloSelfConfiguringCell>(_ cellType: T.Type) {
17 + register(cellType, forCellWithReuseIdentifier: String(describing: cellType))
18 + }
19 +
20 + /// Dequeues a registered `JomloSelfConfiguringCell` and configures it with the given item.
21 + ///
22 + /// - Parameters:
23 + /// - cellType: The cell class to dequeue.
24 + /// - indexPath: The index path to dequeue for.
25 + /// - item: The item to configure the cell with.
26 + /// - Returns: A configured cell.
27 + public func dequeueJomloCell<T: JomloSelfConfiguringCell>(
28 + _ cellType: T.Type,
29 + for indexPath: IndexPath,
30 + item: T.Item
31 + ) -> T {
32 + let identifier = String(describing: cellType)
33 + guard let cell = dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath) as? T
34 + else {
35 + fatalError("Failed to dequeue cell of type \(cellType) with identifier \(identifier)")
36 + }
37 + cell.configure(with: item)
38 + return cell
39 + }
40 +}
Sources/JomloCollectionView/JomloCollectionView.swift new
+20
@@ -0,0 +1,20 @@
1 +//
2 +// JomloCollectionView.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +/// JomloCollectionView is a declarative, section-driven UICollectionView compositional layout engine.
10 +///
11 +/// Inspired by production streaming-app architectures, it provides a protocol-oriented approach
12 +/// to building complex collection view layouts with minimal boilerplate.
13 +///
14 +/// Key concepts:
15 +/// - `JomloSection`: A protocol defining a section's items, layout, and cell configuration
16 +/// - `JomloItem`: A protocol for type-safe, self-configuring cell view models
17 +/// - `JomloCollectionViewController`: A ready-to-use view controller that orchestrates sections
18 +/// - `JomloAnimator`: Declarative cell appearance animations
19 +///
20 +/// Swift 6 strict concurrency is enforced throughout.
Sources/JomloCollectionView/Layout/JomloLayoutFactory.swift new
+239
@@ -0,0 +1,239 @@
1 +//
2 +// JomloLayoutFactory.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A factory providing common compositional layout sections.
12 +///
13 +/// These are ready-to-use layout configurations inspired by streaming-app patterns:
14 +/// hero banners, horizontal shelves, grids, and lists.
15 +@MainActor
16 +public enum JomloLayoutFactory {
17 +
18 + // MARK: - Hero / Feature
19 +
20 + /// A full-width hero section with optional paging behavior.
21 + ///
22 + /// - Parameters:
23 + /// - height: The height of the hero section.
24 + /// - isPaging: Whether the section pages between items.
25 + /// - interItemSpacing: Spacing between items.
26 + /// - Returns: A configured layout section.
27 + public static func hero(
28 + height: NSCollectionLayoutDimension = .fractionalWidth(0.56),
29 + isPaging: Bool = true,
30 + interItemSpacing: CGFloat = 0
31 + ) -> NSCollectionLayoutSection {
32 + let itemSize = NSCollectionLayoutSize(
33 + widthDimension: .fractionalWidth(1.0),
34 + heightDimension: .fractionalHeight(1.0)
35 + )
36 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
37 +
38 + let groupSize = NSCollectionLayoutSize(
39 + widthDimension: .fractionalWidth(0.92),
40 + heightDimension: height
41 + )
42 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
43 +
44 + let section = NSCollectionLayoutSection(group: group)
45 + section.orthogonalScrollingBehavior = isPaging ? .groupPagingCentered : .continuous
46 + section.interGroupSpacing = interItemSpacing
47 + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
48 + return section
49 + }
50 +
51 + // MARK: - Horizontal Shelf
52 +
53 + /// A horizontally scrolling shelf of items (e.g., movie posters, show cards).
54 + ///
55 + /// - Parameters:
56 + /// - itemWidth: The width of each item.
57 + /// - itemHeight: The height of each item.
58 + /// - interItemSpacing: Spacing between items.
59 + /// - sectionInsets: Insets around the section.
60 + /// - scrollBehavior: The orthogonal scrolling behavior.
61 + /// - headerHeight: Optional header height (pass nil for no header).
62 + /// - Returns: A configured layout section.
63 + public static func horizontalShelf(
64 + itemWidth: NSCollectionLayoutDimension = .absolute(140),
65 + itemHeight: NSCollectionLayoutDimension = .absolute(200),
66 + interItemSpacing: CGFloat = 12,
67 + sectionInsets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(
68 + top: 8, leading: 16, bottom: 24, trailing: 16),
69 + scrollBehavior: UICollectionLayoutSectionOrthogonalScrollingBehavior = .continuous,
70 + headerHeight: NSCollectionLayoutDimension? = .estimated(44)
71 + ) -> NSCollectionLayoutSection {
72 + let itemSize = NSCollectionLayoutSize(
73 + widthDimension: .fractionalWidth(1.0),
74 + heightDimension: .fractionalHeight(1.0)
75 + )
76 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
77 +
78 + let groupSize = NSCollectionLayoutSize(
79 + widthDimension: itemWidth,
80 + heightDimension: itemHeight
81 + )
82 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
83 +
84 + let section = NSCollectionLayoutSection(group: group)
85 + section.orthogonalScrollingBehavior = scrollBehavior
86 + section.interGroupSpacing = interItemSpacing
87 + section.contentInsets = sectionInsets
88 +
89 + if let headerHeight {
90 + let headerSize = NSCollectionLayoutSize(
91 + widthDimension: .fractionalWidth(1.0),
92 + heightDimension: headerHeight
93 + )
94 + let header = NSCollectionLayoutBoundarySupplementaryItem(
95 + layoutSize: headerSize,
96 + elementKind: UICollectionView.elementKindSectionHeader,
97 + alignment: .top
98 + )
99 + section.boundarySupplementaryItems = [header]
100 + }
101 +
102 + return section
103 + }
104 +
105 + // MARK: - Grid
106 +
107 + /// A grid layout with a configurable number of columns.
108 + ///
109 + /// - Parameters:
110 + /// - columns: Number of columns in the grid.
111 + /// - itemHeight: The height of each item.
112 + /// - interItemSpacing: Spacing between items (both horizontal and vertical).
113 + /// - sectionInsets: Insets around the section.
114 + /// - headerHeight: Optional header height.
115 + /// - Returns: A configured layout section.
116 + public static func grid(
117 + columns: Int = 2,
118 + itemHeight: NSCollectionLayoutDimension = .fractionalWidth(0.5),
119 + interItemSpacing: CGFloat = 12,
120 + sectionInsets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(
121 + top: 8, leading: 16, bottom: 24, trailing: 16),
122 + headerHeight: NSCollectionLayoutDimension? = .estimated(44)
123 + ) -> NSCollectionLayoutSection {
124 + let itemSize = NSCollectionLayoutSize(
125 + widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
126 + heightDimension: itemHeight
127 + )
128 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
129 + item.contentInsets = NSDirectionalEdgeInsets(
130 + top: 0,
131 + leading: interItemSpacing / 2,
132 + bottom: interItemSpacing,
133 + trailing: interItemSpacing / 2
134 + )
135 +
136 + let groupSize = NSCollectionLayoutSize(
137 + widthDimension: .fractionalWidth(1.0),
138 + heightDimension: itemHeight
139 + )
140 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
141 +
142 + let section = NSCollectionLayoutSection(group: group)
143 + section.contentInsets = sectionInsets
144 +
145 + if let headerHeight {
146 + let headerSize = NSCollectionLayoutSize(
147 + widthDimension: .fractionalWidth(1.0),
148 + heightDimension: headerHeight
149 + )
150 + let header = NSCollectionLayoutBoundarySupplementaryItem(
151 + layoutSize: headerSize,
152 + elementKind: UICollectionView.elementKindSectionHeader,
153 + alignment: .top
154 + )
155 + section.boundarySupplementaryItems = [header]
156 + }
157 +
158 + return section
159 + }
160 +
161 + // MARK: - List
162 +
163 + /// A vertical list layout (single column, full width).
164 + ///
165 + /// - Parameters:
166 + /// - rowHeight: The height of each row.
167 + /// - interItemSpacing: Spacing between rows.
168 + /// - sectionInsets: Insets around the section.
169 + /// - headerHeight: Optional header height.
170 + /// - Returns: A configured layout section.
171 + public static func list(
172 + rowHeight: NSCollectionLayoutDimension = .estimated(60),
173 + interItemSpacing: CGFloat = 1,
174 + sectionInsets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(
175 + top: 8, leading: 16, bottom: 24, trailing: 16),
176 + headerHeight: NSCollectionLayoutDimension? = .estimated(44)
177 + ) -> NSCollectionLayoutSection {
178 + let itemSize = NSCollectionLayoutSize(
179 + widthDimension: .fractionalWidth(1.0),
180 + heightDimension: rowHeight
181 + )
182 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
183 +
184 + let groupSize = NSCollectionLayoutSize(
185 + widthDimension: .fractionalWidth(1.0),
186 + heightDimension: rowHeight
187 + )
188 + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
189 +
190 + let section = NSCollectionLayoutSection(group: group)
191 + section.interGroupSpacing = interItemSpacing
192 + section.contentInsets = sectionInsets
193 +
194 + if let headerHeight {
195 + let headerSize = NSCollectionLayoutSize(
196 + widthDimension: .fractionalWidth(1.0),
197 + heightDimension: headerHeight
198 + )
199 + let header = NSCollectionLayoutBoundarySupplementaryItem(
200 + layoutSize: headerSize,
201 + elementKind: UICollectionView.elementKindSectionHeader,
202 + alignment: .top
203 + )
204 + section.boundarySupplementaryItems = [header]
205 + }
206 +
207 + return section
208 + }
209 +
210 + // MARK: - Wide Banner
211 +
212 + /// A full-width banner section (e.g., for promotions or announcements).
213 + ///
214 + /// - Parameters:
215 + /// - height: The banner height.
216 + /// - sectionInsets: Insets around the section.
217 + /// - Returns: A configured layout section.
218 + public static func banner(
219 + height: NSCollectionLayoutDimension = .absolute(120),
220 + sectionInsets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(
221 + top: 8, leading: 16, bottom: 24, trailing: 16)
222 + ) -> NSCollectionLayoutSection {
223 + let itemSize = NSCollectionLayoutSize(
224 + widthDimension: .fractionalWidth(1.0),
225 + heightDimension: .fractionalHeight(1.0)
226 + )
227 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
228 +
229 + let groupSize = NSCollectionLayoutSize(
230 + widthDimension: .fractionalWidth(1.0),
231 + heightDimension: height
232 + )
233 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
234 +
235 + let section = NSCollectionLayoutSection(group: group)
236 + section.contentInsets = sectionInsets
237 + return section
238 + }
239 +}
Sources/JomloCollectionView/Section/JomloItem.swift new
+51
@@ -0,0 +1,51 @@
1 +//
2 +// JomloItem.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A protocol for items (view models) within a `JomloSection`.
12 +///
13 +/// Each item represents a single cell's data and knows how to configure its cell.
14 +/// Items must be `Hashable` for diffable data source compatibility and `Sendable`
15 +/// for Swift 6 concurrency safety.
16 +@MainActor
17 +public protocol JomloItem: Hashable, Sendable {
18 +
19 + /// A unique string identifier for this item, used for diffing.
20 + /// Must be nonisolated so it can be used in Hashable/Equatable conformances.
21 + nonisolated var itemIdentifier: String { get }
22 +}
23 +
24 +/// A type-erased wrapper around `JomloItem` for use in heterogeneous collections.
25 +///
26 +/// This wrapper captures the item's identifier at initialization time to allow
27 +/// nonisolated access for Hashable/Equatable conformance required by diffable data sources.
28 +public struct AnyJomloItem: Hashable, Sendable {
29 +
30 + @MainActor
31 + public let base: any JomloItem
32 +
33 + private let _itemIdentifier: String
34 +
35 + @MainActor
36 + public init(_ item: some JomloItem) {
37 + self.base = item
38 + self._itemIdentifier = item.itemIdentifier
39 + }
40 +
41 + /// The item identifier, accessible from any context.
42 + public var itemIdentifier: String { _itemIdentifier }
43 +
44 + public static func == (lhs: AnyJomloItem, rhs: AnyJomloItem) -> Bool {
45 + lhs._itemIdentifier == rhs._itemIdentifier
46 + }
47 +
48 + public func hash(into hasher: inout Hasher) {
49 + hasher.combine(_itemIdentifier)
50 + }
51 +}
Sources/JomloCollectionView/Section/JomloSection.swift new
+117
@@ -0,0 +1,117 @@
1 +//
2 +// JomloSection.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A Sendable, Hashable wrapper for section identifiers.
12 +///
13 +/// This exists because `AnyHashable` does not conform to `Sendable` in Swift 6,
14 +/// but `NSDiffableDataSourceSnapshot` requires its section identifier type to be `Sendable`.
15 +public struct JomloSectionID: Hashable, Sendable {
16 + public let rawValue: String
17 +
18 + public init(_ rawValue: String) {
19 + self.rawValue = rawValue
20 + }
21 +}
22 +
23 +/// The core protocol that defines a section in a `JomloCollectionView`.
24 +///
25 +/// Each section is responsible for:
26 +/// - Providing its items (view models)
27 +/// - Defining its compositional layout
28 +/// - Configuring cells for its items
29 +/// - Handling item selection
30 +///
31 +/// Sections are composable building blocks — the same section type can render
32 +/// differently based on configuration, following the style-driven reuse pattern.
33 +@MainActor
34 +public protocol JomloSection: AnyObject, Sendable {
35 +
36 + /// A unique, stable identifier for this section.
37 + var identifier: JomloSectionID { get }
38 +
39 + /// Returns the items for this section.
40 + ///
41 + /// Items must conform to `JomloItem` and be `Hashable` + `Sendable`.
42 + /// The diffable data source uses these to compute changes.
43 + func items() -> [any JomloItem]
44 +
45 + /// Creates the compositional layout for this section.
46 + ///
47 + /// - Parameters:
48 + /// - environment: The layout environment providing trait and geometry info.
49 + /// - sectionIndex: The index of this section in the collection view.
50 + /// - Returns: A fully configured `NSCollectionLayoutSection`.
51 + func layoutSection(
52 + environment: NSCollectionLayoutEnvironment,
53 + sectionIndex: Int
54 + ) -> NSCollectionLayoutSection
55 +
56 + /// Provides a configured cell for the given item.
57 + ///
58 + /// - Parameters:
59 + /// - item: The item to configure a cell for.
60 + /// - collectionView: The parent collection view.
61 + /// - indexPath: The index path for the cell.
62 + /// - Returns: A configured `UICollectionViewCell`.
63 + func cell(
64 + for item: any JomloItem,
65 + in collectionView: UICollectionView,
66 + at indexPath: IndexPath
67 + ) -> UICollectionViewCell
68 +
69 + /// Called when an item in this section is selected.
70 + ///
71 + /// - Parameters:
72 + /// - item: The selected item.
73 + /// - indexPath: The index path of the selected item.
74 + func didSelectItem(_ item: any JomloItem, at indexPath: IndexPath)
75 +
76 + /// Optional supplementary view provider for this section.
77 + ///
78 + /// - Parameters:
79 + /// - kind: The supplementary view kind (e.g., header, footer).
80 + /// - collectionView: The parent collection view.
81 + /// - indexPath: The index path for the supplementary view.
82 + /// - Returns: A configured supplementary view, or `nil`.
83 + func supplementaryView(
84 + ofKind kind: String,
85 + in collectionView: UICollectionView,
86 + at indexPath: IndexPath
87 + ) -> UICollectionReusableView?
88 +
89 + /// Called when the section should prefetch upcoming content.
90 + func prefetchIfNeeded()
91 +
92 + /// Optional display animation for cells in this section.
93 + var displayAnimation: JomloAnimator.Animation? { get }
94 +
95 + /// A callback invoked when this section needs to reload its data.
96 + /// Set by the collection view controller.
97 + var onReloadNeeded: (@MainActor @Sendable () -> Void)? { get set }
98 +}
99 +
100 +// MARK: - Default Implementations
101 +
102 +extension JomloSection {
103 +
104 + public func didSelectItem(_ item: any JomloItem, at indexPath: IndexPath) {}
105 +
106 + public func supplementaryView(
107 + ofKind kind: String,
108 + in collectionView: UICollectionView,
109 + at indexPath: IndexPath
110 + ) -> UICollectionReusableView? {
111 + nil
112 + }
113 +
114 + public func prefetchIfNeeded() {}
115 +
116 + public var displayAnimation: JomloAnimator.Animation? { nil }
117 +}
Sources/JomloCollectionView/Section/JomloSelfConfiguringCell.swift new
+25
@@ -0,0 +1,25 @@
1 +//
2 +// JomloSelfConfiguringCell.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A protocol for cells that can configure themselves given an item.
12 +///
13 +/// This is the UICollectionView equivalent of JomloTableView's `populateView(cell:)` pattern,
14 +/// but type-safe and inverted — the cell knows how to configure itself.
15 +@MainActor
16 +public protocol JomloSelfConfiguringCell: UICollectionViewCell {
17 +
18 + /// The type of item this cell can display.
19 + associatedtype Item: JomloItem
20 +
21 + /// Configures the cell with the given item.
22 + ///
23 + /// - Parameter item: The item containing the data to display.
24 + func configure(with item: Item)
25 +}
Sources/JomloCollectionView/ViewController/JomloCollectionViewController.swift new
+243
@@ -0,0 +1,243 @@
1 +//
2 +// JomloCollectionViewController.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import UIKit
10 +
11 +/// A view controller that manages a `UICollectionView` with compositional layout,
12 +/// driven by an array of `JomloSection` instances.
13 +///
14 +/// This is the collection-view equivalent of `JomloTableView` — it removes the need
15 +/// to manually conform to data source and delegate protocols. Instead, you provide
16 +/// sections that define their own layout, items, and cell configuration.
17 +///
18 +/// ## Usage
19 +/// ```swift
20 +/// let vc = JomloCollectionViewController(sections: [
21 +/// heroSection,
22 +/// trendingShelf,
23 +/// categoriesGrid
24 +/// ])
25 +/// navigationController?.pushViewController(vc, animated: true)
26 +/// ```
27 +@MainActor
28 +open class JomloCollectionViewController: UIViewController {
29 +
30 + // MARK: - Public Properties
31 +
32 + /// The managed collection view.
33 + public private(set) lazy var collectionView: UICollectionView = {
34 + let cv = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
35 + cv.translatesAutoresizingMaskIntoConstraints = false
36 + #if os(tvOS)
37 + cv.backgroundColor = .clear
38 + #else
39 + cv.backgroundColor = .systemBackground
40 + #endif
41 + cv.delegate = self
42 + return cv
43 + }()
44 +
45 + /// The sections driving this collection view.
46 + public private(set) var sections: [any JomloSection]
47 +
48 + /// Optional animator for cell display animations.
49 + public var animator: JomloAnimator?
50 +
51 + // MARK: - Private Properties
52 +
53 + private var dataSource: UICollectionViewDiffableDataSource<JomloSectionID, AnyJomloItem>!
54 +
55 + // MARK: - Initialization
56 +
57 + /// Creates a new collection view controller with the given sections.
58 + ///
59 + /// - Parameter sections: An array of sections to display.
60 + public init(sections: [any JomloSection] = []) {
61 + self.sections = sections
62 + super.init(nibName: nil, bundle: nil)
63 + }
64 +
65 + @available(*, unavailable)
66 + public required init?(coder: NSCoder) {
67 + fatalError("init(coder:) is not supported. Use init(sections:) instead.")
68 + }
69 +
70 + // MARK: - Lifecycle
71 +
72 + open override func viewDidLoad() {
73 + super.viewDidLoad()
74 + setupCollectionView()
75 + configureDataSource()
76 + bindSections()
77 + applySnapshot()
78 + }
79 +
80 + // MARK: - Public Methods
81 +
82 + /// Replaces all sections and reloads the collection view.
83 + ///
84 + /// - Parameter sections: The new sections to display.
85 + /// - Parameter animated: Whether to animate the transition.
86 + public func setSections(_ sections: [any JomloSection], animated: Bool = true) {
87 + self.sections = sections
88 + bindSections()
89 + applySnapshot(animated: animated)
90 + collectionView.setCollectionViewLayout(makeLayout(), animated: animated)
91 + }
92 +
93 + /// Appends a section and animates it in.
94 + ///
95 + /// - Parameter section: The section to append.
96 + /// - Parameter animated: Whether to animate the insertion.
97 + public func appendSection(_ section: any JomloSection, animated: Bool = true) {
98 + sections.append(section)
99 + bindSection(section)
100 + applySnapshot(animated: animated)
101 + collectionView.setCollectionViewLayout(makeLayout(), animated: animated)
102 + }
103 +
104 + /// Reloads a specific section by its identifier.
105 + ///
106 + /// - Parameter identifier: The identifier of the section to reload.
107 + /// - Parameter animated: Whether to animate the reload.
108 + public func reloadSection(identifier: JomloSectionID, animated: Bool = true) {
109 + applySnapshot(animated: animated)
110 + }
111 +
112 + /// Triggers a full snapshot reapply.
113 + ///
114 + /// - Parameter animated: Whether to animate changes.
115 + public func reloadData(animated: Bool = true) {
116 + applySnapshot(animated: animated)
117 + }
118 +
119 + // MARK: - Private Methods
120 +
121 + private func setupCollectionView() {
122 + view.addSubview(collectionView)
123 + NSLayoutConstraint.activate([
124 + collectionView.topAnchor.constraint(equalTo: view.topAnchor),
125 + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
126 + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
127 + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
128 + ])
129 + }
130 +
131 + private func makeLayout() -> UICollectionViewCompositionalLayout {
132 + UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
133 + guard let self, sectionIndex < self.sections.count else {
134 + return Self.makeEmptyLayoutSection()
135 + }
136 + return self.sections[sectionIndex].layoutSection(
137 + environment: environment,
138 + sectionIndex: sectionIndex
139 + )
140 + }
141 + }
142 +
143 + private static func makeEmptyLayoutSection() -> NSCollectionLayoutSection {
144 + let itemSize = NSCollectionLayoutSize(
145 + widthDimension: .fractionalWidth(1.0),
146 + heightDimension: .fractionalHeight(1.0)
147 + )
148 + let item = NSCollectionLayoutItem(layoutSize: itemSize)
149 + let groupSize = NSCollectionLayoutSize(
150 + widthDimension: .fractionalWidth(1.0),
151 + heightDimension: .absolute(0)
152 + )
153 + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
154 + return NSCollectionLayoutSection(group: group)
155 + }
156 +
157 + private func configureDataSource() {
158 + dataSource = UICollectionViewDiffableDataSource<JomloSectionID, AnyJomloItem>(
159 + collectionView: collectionView
160 + ) { [weak self] collectionView, indexPath, itemWrapper in
161 + guard let self else { return UICollectionViewCell() }
162 + guard indexPath.section < self.sections.count else { return UICollectionViewCell() }
163 +
164 + let section = self.sections[indexPath.section]
165 + return section.cell(for: itemWrapper.base, in: collectionView, at: indexPath)
166 + }
167 +
168 + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
169 + guard let self else { return nil }
170 + guard indexPath.section < self.sections.count else { return nil }
171 +
172 + let section = self.sections[indexPath.section]
173 + return section.supplementaryView(ofKind: kind, in: collectionView, at: indexPath)
174 + }
175 + }
176 +
177 + private func bindSections() {
178 + for section in sections {
179 + bindSection(section)
180 + }
181 + }
182 +
183 + private func bindSection(_ section: any JomloSection) {
184 + section.onReloadNeeded = { [weak self] in
185 + self?.applySnapshot(animated: true)
186 + }
187 + }
188 +
189 + private func applySnapshot(animated: Bool = true) {
190 + var snapshot = NSDiffableDataSourceSnapshot<JomloSectionID, AnyJomloItem>()
191 +
192 + for section in sections {
193 + let sectionId = section.identifier
194 + snapshot.appendSections([sectionId])
195 + let items = section.items().map { AnyJomloItem($0) }
196 + snapshot.appendItems(items, toSection: sectionId)
197 + }
198 +
199 + dataSource.apply(snapshot, animatingDifferences: animated)
200 + }
201 +}
202 +
203 +// MARK: - UICollectionViewDelegate
204 +
205 +extension JomloCollectionViewController: UICollectionViewDelegate {
206 +
207 + open func collectionView(
208 + _ collectionView: UICollectionView,
209 + didSelectItemAt indexPath: IndexPath
210 + ) {
211 + guard indexPath.section < sections.count else { return }
212 + let section = sections[indexPath.section]
213 + let items = section.items()
214 + guard indexPath.item < items.count else { return }
215 + section.didSelectItem(items[indexPath.item], at: indexPath)
216 + }
217 +
218 + open func collectionView(
219 + _ collectionView: UICollectionView,
220 + willDisplay cell: UICollectionViewCell,
221 + forItemAt indexPath: IndexPath
222 + ) {
223 + guard indexPath.section < sections.count else { return }
224 + let section = sections[indexPath.section]
225 +
226 + // Section-specific animation
227 + if let animation = section.displayAnimation {
228 + animation(cell, indexPath, collectionView)
229 + }
230 + // Global animator
231 + else if let animator {
232 + animator.animate(view: cell, at: indexPath, in: collectionView)
233 + }
234 + }
235 +
236 + open func collectionView(
237 + _ collectionView: UICollectionView,
238 + didEndDisplaying cell: UICollectionViewCell,
239 + forItemAt indexPath: IndexPath
240 + ) {
241 + // Can be overridden by subclasses
242 + }
243 +}
Tests/JomloCollectionViewTests/JomloCollectionViewTests.swift new
+119
@@ -0,0 +1,119 @@
1 +//
2 +// JomloCollectionViewTests.swift
3 +// JomloCollectionView
4 +//
5 +// Created by Seto Elkahfi on 2026-01-27.
6 +// Copyright © 2026 Seto Elkahfi. All rights reserved.
7 +//
8 +
9 +import Testing
10 +import UIKit
11 +
12 +@testable import JomloCollectionView
13 +
14 +@MainActor
15 +struct JomloCollectionViewTests {
16 +
17 + @Test("JomloCollectionViewController initializes with empty sections")
18 + func emptyInitialization() {
19 + let vc = JomloCollectionViewController(sections: [])
20 + vc.loadViewIfNeeded()
21 + #expect(vc.sections.isEmpty)
22 + #expect(vc.collectionView.superview != nil)
23 + }
24 +
25 + @Test("JomloCollectionViewController accepts sections")
26 + func sectionsAreSet() {
27 + let section = MockSection(id: "test")
28 + let vc = JomloCollectionViewController(sections: [section])
29 + vc.loadViewIfNeeded()
30 + #expect(vc.sections.count == 1)
31 + }
32 +
33 + @Test("JomloAnimator stops after all visible cells animated")
34 + func animatorStops() {
35 + var animationCount = 0
36 + let animator = JomloAnimator { _, _, _ in
37 + animationCount += 1
38 + }
39 + #expect(animationCount == 0)
40 + // Animator created successfully
41 + }
42 +
43 + @Test("AnyJomloItem wraps items correctly")
44 + func anyItemWrapping() {
45 + let item = MockItem(id: "item1", title: "Test")
46 + let wrapped = AnyJomloItem(item)
47 + #expect(wrapped.itemIdentifier == "item1")
48 + }
49 +
50 + @Test("JomloLayoutFactory creates hero section")
51 + func heroLayout() {
52 + let section = JomloLayoutFactory.hero()
53 + #expect(section.orthogonalScrollingBehavior == .groupPagingCentered)
54 + }
55 +
56 + @Test("JomloLayoutFactory creates horizontal shelf")
57 + func shelfLayout() {
58 + let section = JomloLayoutFactory.horizontalShelf()
59 + #expect(section.orthogonalScrollingBehavior == .continuous)
60 + }
61 +
62 + @Test("Section header view configures correctly")
63 + func headerConfiguration() {
64 + let header = JomloSectionHeaderView(frame: .zero)
65 + header.configure(title: "Trending", actionTitle: "See All")
66 + #expect(header.titleLabel.text == "Trending")
67 + #expect(header.actionButton.isHidden == false)
68 + }
69 +
70 + @Test("Section header hides action button when nil")
71 + func headerNoAction() {
72 + let header = JomloSectionHeaderView(frame: .zero)
73 + header.configure(title: "Movies")
74 + #expect(header.actionButton.isHidden == true)
75 + }
76 +}
77 +
78 +// MARK: - Test Helpers
79 +
80 +@MainActor
81 +final class MockSection: JomloSection {
82 + let identifier: JomloSectionID
83 + var onReloadNeeded: (@MainActor @Sendable () -> Void)?
84 +
85 + init(id: String) {
86 + self.identifier = JomloSectionID(id)
87 + }
88 +
89 + func items() -> [any JomloItem] {
90 + [MockItem(id: "1", title: "Mock")]
91 + }
92 +
93 + func layoutSection(environment: NSCollectionLayoutEnvironment, sectionIndex: Int)
94 + -> NSCollectionLayoutSection
95 + {
96 + JomloLayoutFactory.list()
97 + }
98 +
99 + func cell(for item: any JomloItem, in collectionView: UICollectionView, at indexPath: IndexPath)
100 + -> UICollectionViewCell
101 + {
102 + UICollectionViewCell()
103 + }
104 +}
105 +
106 +struct MockItem: JomloItem {
107 + let id: String
108 + let title: String
109 +
110 + nonisolated var itemIdentifier: String { id }
111 +
112 + nonisolated func hash(into hasher: inout Hasher) {
113 + hasher.combine(id)
114 + }
115 +
116 + nonisolated static func == (lhs: MockItem, rhs: MockItem) -> Bool {
117 + lhs.id == rhs.id
118 + }
119 +}
assets/Simulator Screenshot - iPhone 17 Pro - 2026-05-18 at 19.41.17.png
Binary files /dev/null and b/assets/Simulator Screenshot - iPhone 17 Pro - 2026-05-18 at 19.41.17.png differ
assets/Simulator Screenshot - iPhone 17 Pro - 2026-05-18 at 19.41.25.png
Binary files /dev/null and b/assets/Simulator Screenshot - iPhone 17 Pro - 2026-05-18 at 19.41.25.png differ
assets/Simulator Screenshot - iPhone 17 Pro - 2026-05-18 at 19.41.37.png
Binary files /dev/null and b/assets/Simulator Screenshot - iPhone 17 Pro - 2026-05-18 at 19.41.37.png differ
mise.toml new
+2
@@ -0,0 +1,2 @@
1 +[tools]
2 +ruby = "latest"