Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Rebuild/Rebuild.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.2;
MARKETING_VERSION = 1.0.7;
MARKETING_VERSION = 1.7.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
Expand All @@ -446,7 +446,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Rebuild/Rebuild.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 801;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Rebuild/Preview Content\"";
DEVELOPMENT_TEAM = 4H8SL3B8YS;
ENABLE_PREVIEWS = YES;
Expand All @@ -466,7 +466,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.2;
MARKETING_VERSION = 1.0.7;
MARKETING_VERSION = 1.7.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#if canImport(UIKit)
import UIKit
#endif
import SnapKit

public protocol FilterableFavouritableItemListDelegate: AnyObject {
func filterableList(_ list: FilterableFavouritableItemList, didUpdateQuery query: String?)
func filterableListDidRequestRefresh(_ list: FilterableFavouritableItemList)
func filterableList(_ list: FilterableFavouritableItemList, didSelect item: ItemDisplayable)
func filterableList(_ list: FilterableFavouritableItemList, didTapFavoriteFor item: ItemDisplayable)
}

public extension FilterableFavouritableItemListDelegate {
func filterableList(_ list: FilterableFavouritableItemList, didUpdateQuery query: String?) {}
func filterableListDidRequestRefresh(_ list: FilterableFavouritableItemList) {}
func filterableList(_ list: FilterableFavouritableItemList, didSelect item: ItemDisplayable) {}
func filterableList(_ list: FilterableFavouritableItemList, didTapFavoriteFor item: ItemDisplayable) {}
}

public final class FilterableFavouritableItemList: UIViewController {
// MARK: - Public API

public weak var delegate: FilterableFavouritableItemListDelegate?

public var searchPlaceholder: String? {
didSet {
searchBar.placeholder = searchPlaceholder
}
}

public var showsRefreshControl: Bool = true {
didSet {
updateRefreshControlVisibility()
}
}

public func display(items: [ItemDisplayable], scrollToTop: Bool = false) {
self.items = items
collectionView.reloadData()
if scrollToTop, !items.isEmpty {
let indexPath = IndexPath(item: 0, section: 0)
collectionView.layoutIfNeeded()
collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
}
refreshControl.endRefreshing()
}

public func endRefreshing() {
refreshControl.endRefreshing()
}

public func beginRefreshing() {
if !refreshControl.isRefreshing {
refreshControl.beginRefreshing()
}
}

// MARK: - Private Properties

private let padding: CGFloat = 8
private let searchBarHeight: CGFloat = 60
private var items: [ItemDisplayable] = []

private let searchBar: UISearchBar = UISearchBar()
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = padding
layout.minimumInteritemSpacing = 0
layout.sectionInset = UIEdgeInsets(top: padding, left: padding, bottom: padding, right: padding)

let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = .clear
collectionView.delegate = self
collectionView.dataSource = self
collectionView.alwaysBounceVertical = true
collectionView.register(MovieItemCell.self, forCellWithReuseIdentifier: MovieItemCell.reuseIdentifier)
return collectionView
}()

private let refreshControl = UIRefreshControl()

// MARK: - Lifecycle

public override func viewDidLoad() {
super.viewDidLoad()
setupView()
}

// MARK: - Setup

private func setupView() {
view.backgroundColor = ThemeService.lightGrey
setupSearchBar()
setupCollectionView()
setupRefreshControl()
updateRefreshControlVisibility()
}

private func setupSearchBar() {
searchBar.searchBarStyle = .prominent
searchBar.tintColor = .white
searchBar.barTintColor = .white
searchBar.delegate = self
searchBar.placeholder = searchPlaceholder ?? "Filter..."
searchBar.isTranslucent = true
searchBar.backgroundColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.75)
searchBar.backgroundImage = UIImage()

if #available(iOS 13.0, *) {
searchBar.searchTextField.backgroundColor = .clear
} else if let textField = searchBar.value(forKey: "searchField") as? UITextField {
textField.backgroundColor = .clear
}

view.addSubview(searchBar)
searchBar.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide.snp.top)
make.left.right.equalToSuperview()
make.height.equalTo(searchBarHeight)
}
}

private func setupCollectionView() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.top.equalTo(searchBar.snp.bottom)
make.left.right.bottom.equalToSuperview()
}
}

private func setupRefreshControl() {
refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
}

private func updateRefreshControlVisibility() {
guard isViewLoaded else { return }
if showsRefreshControl {
if #available(iOS 10.0, *) {
collectionView.refreshControl = refreshControl
} else if refreshControl.superview == nil {
collectionView.addSubview(refreshControl)
}
} else {
if #available(iOS 10.0, *) {
collectionView.refreshControl = nil
} else {
refreshControl.removeFromSuperview()
}
}
}

@objc private func handleRefresh() {
delegate?.filterableListDidRequestRefresh(self)
}
}

// MARK: - UICollectionViewDataSource

extension FilterableFavouritableItemList: UICollectionViewDataSource {
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
items.count
}

public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: MovieItemCell.reuseIdentifier,
for: indexPath
) as? MovieItemCell else {
return UICollectionViewCell()
}

let item = items[indexPath.item]
cell.delegate = self
cell.configure(with: item)
return cell
}
}

// MARK: - UICollectionViewDelegateFlowLayout

extension FilterableFavouritableItemList: UICollectionViewDelegateFlowLayout {
public func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let width = Int(collectionView.bounds.size.width) - (Int(padding) * 2)
let height = ThemeService.cellsHeight
return CGSize(width: width, height: height)
}
}

// MARK: - UICollectionViewDelegate

extension FilterableFavouritableItemList: UICollectionViewDelegate {
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let item = items[indexPath.item]
delegate?.filterableList(self, didSelect: item)
}
}

// MARK: - UISearchBarDelegate

extension FilterableFavouritableItemList: UISearchBarDelegate {
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
let sanitizedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let query = sanitizedQuery.isEmpty ? nil : sanitizedQuery
delegate?.filterableList(self, didUpdateQuery: query)
}

public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchBar.resignFirstResponder()
}
}

// MARK: - FavButtonDelegate

extension FilterableFavouritableItemList: FavButtonDelegate {
public func favButtonTapped(for item: ItemDisplayable) {
delegate?.filterableList(self, didTapFavoriteFor: item)
}
}

private extension MovieItemCell {
static var reuseIdentifier: String {
"MovieItemCell"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@
descContainer.addSubview(favButton)
}

private func setupConstraints() {

Check warning on line 221 in Sources/Shared_UI_Support/Views/ListItem/MovieItemCell.swift

View workflow job for this annotation

GitHub Actions / build

Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines (function_body_length)
imageContainer.snp.makeConstraints { make in
make.left.equalTo(contentView.snp.left).offset(padding)
make.width.equalTo(contentView.snp.width).multipliedBy(0.4).offset(-padding)
Expand Down Expand Up @@ -354,7 +354,7 @@
}

extension UIImage {
func averageColor() -> UIColor? {
public func averageColor() -> UIColor? {
guard let inputImage = CIImage(image: self) else { return nil }
let extentVector = CIVector(x: inputImage.extent.origin.x,
y: inputImage.extent.origin.y,
Expand Down Expand Up @@ -405,4 +405,4 @@
@available(iOS 17, *)
#Preview {
MovieItemCell()
}

Check warning on line 408 in Sources/Shared_UI_Support/Views/ListItem/MovieItemCell.swift

View workflow job for this annotation

GitHub Actions / build

File should contain 400 lines or less: currently contains 408 (file_length)
76 changes: 67 additions & 9 deletions Sources/TMDB/View/FloatingTabItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,25 @@ struct FloatingTabBar<Selection: Hashable>: View {
@Binding var isHidden: Bool
let items: [FloatingTabItem<Selection>]

@Namespace private var glassNamespace

var body: some View {
ZStack {
if #available(iOS 26, *) {
liquidGlassTabBar
} else {
legacyTabBar
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isHidden)
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: selection)
}

// MARK: - iOS 26+ Liquid Glass Tab Bar

@available(iOS 26, *)
private var liquidGlassTabBar: some View {
GlassEffectContainer(spacing: 0) {
HStack(spacing: 0) {
ForEach(items, id: \.tag) { item in
Button(action: {
Expand All @@ -34,20 +51,61 @@ struct FloatingTabBar<Selection: Hashable>: View {
))
}
}
.foregroundColor(item.tag == selection ? .blue : .gray)
.foregroundStyle(item.tag == selection ? .primary : .secondary)
.padding(.vertical, 8)
.padding(.horizontal, 12)
.glassEffect(
item.tag == selection
? .regular.interactive()
: .regular
)
.glassEffectID("\(item.tag)", in: glassNamespace)
}
.buttonStyle(.plain)
}
}
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.padding(.horizontal)
.offset(y: isHidden ? 100 : 0) // Move down when hidden
.opacity(isHidden ? 0 : 1) // Fade out when hidden
.padding(4)
.glassEffect(in: .capsule)
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isHidden)
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: selection)
.padding(.horizontal)
.offset(y: isHidden ? 100 : 0)
.opacity(isHidden ? 0 : 1)
}

// MARK: - Legacy Tab Bar (iOS < 26)

private var legacyTabBar: some View {
HStack(spacing: 0) {
ForEach(items, id: \.tag) { item in
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
selection = item.tag
}
}) {
HStack(spacing: 4) {
item.icon
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
if item.tag == selection {
Text(item.title).font(.caption)
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .scale.combined(with: .opacity)
))
}
}
.foregroundColor(item.tag == selection ? .blue : .gray)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
}
}
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.padding(.horizontal)
.offset(y: isHidden ? 100 : 0)
.opacity(isHidden ? 0 : 1)
}
}
Loading
Loading