perf(tv): optimize focus animations and disable native glass effect

This commit is contained in:
Fredrik Burmester
2026-01-31 21:34:49 +01:00
parent f549e8eaed
commit e6598f0944
8 changed files with 78 additions and 53 deletions

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -134,27 +134,16 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
// Track focus within section for item focus/blur callbacks
const flatListRef = useRef<FlatList<BaseItemDto>>(null); const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [_focusedCount, setFocusedCount] = useState(0);
// Pass through focus callbacks without tracking internal state
const handleItemFocus = useCallback( const handleItemFocus = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
setFocusedCount((c) => c + 1);
onItemFocus?.(item); onItemFocus?.(item);
}, },
[onItemFocus], [onItemFocus],
); );
const handleItemBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
// Focus handler for See All card (doesn't need item parameter)
const handleSeeAllFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: queryKey, queryKey: queryKey,
@@ -234,7 +223,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onLongPress={() => showItemActions(item)} onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem} hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)} onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
width={itemWidth} width={itemWidth}
/> />
</View> </View>
@@ -247,7 +235,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
handleItemPress, handleItemPress,
showItemActions, showItemActions,
handleItemFocus, handleItemFocus,
handleItemBlur,
ITEM_GAP, ITEM_GAP,
], ],
); );
@@ -370,8 +357,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPress={handleSeeAllPress} onPress={handleSeeAllPress}
orientation={orientation} orientation={orientation}
disabled={disabled} disabled={disabled}
onFocus={handleSeeAllFocus}
onBlur={handleItemBlur}
typography={typography} typography={typography}
posterSizes={posterSizes} posterSizes={posterSizes}
/> />

View File

@@ -157,6 +157,8 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
borderRadius: 24, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
transform: [{ scale }], transform: [{ scale }],
borderWidth: 2,
borderColor: focused ? "#FFFFFF" : "transparent",
shadowColor: "#FFFFFF", shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,

View File

@@ -56,8 +56,8 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 14, marginBottom: 14,
borderWidth: focused ? 3 : 0, borderWidth: 2,
borderColor: "#fff", borderColor: focused ? "#FFFFFF" : "transparent",
}} }}
> >
{imageUrl ? ( {imageUrl ? (

View File

@@ -397,6 +397,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
aspectRatio, aspectRatio,
borderRadius: 24, borderRadius: 24,
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
borderWidth: 2,
borderColor: focused ? "#FFFFFF" : "transparent",
}} }}
/> />
); );
@@ -432,6 +434,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
borderRadius: 24, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
borderWidth: 2,
borderColor: focused ? "#FFFFFF" : "transparent",
}} }}
> >
<Image <Image

View File

@@ -69,6 +69,8 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
borderRadius: 24, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: 2,
borderColor: focused ? "#FFFFFF" : "transparent",
}} }}
> >
{imageUrl ? ( {imageUrl ? (

View File

@@ -1,11 +1,23 @@
import ExpoModulesCore import ExpoModulesCore
import SwiftUI import SwiftUI
import UIKit import UIKit
import Combine
/// Observable state that SwiftUI can watch for changes without rebuilding the entire view
class GlassPosterState: ObservableObject {
@Published var imageUrl: String? = nil
@Published var aspectRatio: Double = 10.0 / 15.0
@Published var cornerRadius: Double = 24
@Published var progress: Double = 0
@Published var showWatchedIndicator: Bool = false
@Published var isFocused: Bool = false
@Published var width: Double = 260
}
/// ExpoView wrapper that hosts the SwiftUI GlassPosterView /// ExpoView wrapper that hosts the SwiftUI GlassPosterView
class GlassPosterExpoView: ExpoView { class GlassPosterExpoView: ExpoView {
private var hostingController: UIHostingController<GlassPosterView>? private var hostingController: UIHostingController<GlassPosterViewWrapper>?
private var posterView: GlassPosterView private let state = GlassPosterState()
// Stored dimensions for intrinsic content size // Stored dimensions for intrinsic content size
private var posterWidth: CGFloat = 260 private var posterWidth: CGFloat = 260
@@ -16,13 +28,13 @@ class GlassPosterExpoView: ExpoView {
let onError = EventDispatcher() let onError = EventDispatcher()
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
self.posterView = GlassPosterView()
super.init(appContext: appContext) super.init(appContext: appContext)
setupHostingController() setupHostingController()
} }
private func setupHostingController() { private func setupHostingController() {
let hostingController = UIHostingController(rootView: posterView) let wrapper = GlassPosterViewWrapper(state: state)
let hostingController = UIHostingController(rootView: wrapper)
hostingController.view.backgroundColor = .clear hostingController.view.backgroundColor = .clear
hostingController.view.translatesAutoresizingMaskIntoConstraints = false hostingController.view.translatesAutoresizingMaskIntoConstraints = false
@@ -38,10 +50,6 @@ class GlassPosterExpoView: ExpoView {
self.hostingController = hostingController self.hostingController = hostingController
} }
private func updateHostingController() {
hostingController?.rootView = posterView
}
// Override intrinsic content size for proper React Native layout // Override intrinsic content size for proper React Native layout
override var intrinsicContentSize: CGSize { override var intrinsicContentSize: CGSize {
let height = posterWidth / posterAspectRatio let height = posterWidth / posterAspectRatio
@@ -49,43 +57,38 @@ class GlassPosterExpoView: ExpoView {
} }
// MARK: - Property Setters // MARK: - Property Setters
// These now update the observable state object directly.
// SwiftUI observes state changes and only re-renders affected views.
func setImageUrl(_ url: String?) { func setImageUrl(_ url: String?) {
posterView.imageUrl = url state.imageUrl = url
updateHostingController()
} }
func setAspectRatio(_ ratio: Double) { func setAspectRatio(_ ratio: Double) {
posterView.aspectRatio = ratio state.aspectRatio = ratio
posterAspectRatio = CGFloat(ratio) posterAspectRatio = CGFloat(ratio)
invalidateIntrinsicContentSize() invalidateIntrinsicContentSize()
updateHostingController()
} }
func setWidth(_ width: Double) { func setWidth(_ width: Double) {
posterView.width = width state.width = width
posterWidth = CGFloat(width) posterWidth = CGFloat(width)
invalidateIntrinsicContentSize() invalidateIntrinsicContentSize()
updateHostingController()
} }
func setCornerRadius(_ radius: Double) { func setCornerRadius(_ radius: Double) {
posterView.cornerRadius = radius state.cornerRadius = radius
updateHostingController()
} }
func setProgress(_ progress: Double) { func setProgress(_ progress: Double) {
posterView.progress = progress state.progress = progress
updateHostingController()
} }
func setShowWatchedIndicator(_ show: Bool) { func setShowWatchedIndicator(_ show: Bool) {
posterView.showWatchedIndicator = show state.showWatchedIndicator = show
updateHostingController()
} }
func setIsFocused(_ focused: Bool) { func setIsFocused(_ focused: Bool) {
posterView.isFocused = focused state.isFocused = focused
updateHostingController()
} }
} }

View File

@@ -1,5 +1,24 @@
import SwiftUI import SwiftUI
/// Wrapper view that observes state changes from GlassPosterState
/// This allows SwiftUI to efficiently update only the changed properties
/// instead of rebuilding the entire view hierarchy on every prop change.
struct GlassPosterViewWrapper: View {
@ObservedObject var state: GlassPosterState
var body: some View {
GlassPosterView(
imageUrl: state.imageUrl,
aspectRatio: state.aspectRatio,
cornerRadius: state.cornerRadius,
progress: state.progress,
showWatchedIndicator: state.showWatchedIndicator,
isFocused: state.isFocused,
width: state.width
)
}
}
/// SwiftUI view with tvOS 26 Liquid Glass effect /// SwiftUI view with tvOS 26 Liquid Glass effect
struct GlassPosterView: View { struct GlassPosterView: View {
var imageUrl: String? = nil var imageUrl: String? = nil
@@ -35,7 +54,7 @@ struct GlassPosterView: View {
#endif #endif
} }
// MARK: - tvOS 26+ Glass Effect // MARK: - tvOS 26+ Content (glass effect disabled for now)
#if os(tvOS) #if os(tvOS)
@available(tvOS 26.0, *) @available(tvOS 26.0, *)
@@ -45,6 +64,10 @@ struct GlassPosterView: View {
imageContent imageContent
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
// White border on focus
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0)
// Progress bar overlay // Progress bar overlay
if progress > 0 { if progress > 0 {
progressOverlay progressOverlay
@@ -56,7 +79,6 @@ struct GlassPosterView: View {
} }
} }
.frame(width: width, height: height) .frame(width: width, height: height)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.focusable() .focusable()
.focused($isInternallyFocused) .focused($isInternallyFocused)
.scaleEffect(isCurrentlyFocused ? 1.05 : 1.0) .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0)
@@ -72,9 +94,9 @@ struct GlassPosterView: View {
imageContent imageContent
.clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
// Subtle overlay for depth // White border on focus
RoundedRectangle(cornerRadius: cornerRadius) RoundedRectangle(cornerRadius: cornerRadius)
.fill(.ultraThinMaterial.opacity(0.15)) .stroke(Color.white, lineWidth: isFocused ? 4 : 0)
// Progress bar overlay // Progress bar overlay
if progress > 0 { if progress > 0 {

View File

@@ -21,16 +21,23 @@ if (Platform.OS === "ios" && Platform.isTV) {
/** /**
* Check if the native glass effect is available (tvOS 26+) * Check if the native glass effect is available (tvOS 26+)
* NOTE: Glass effect is currently disabled for performance reasons.
* The native module rebuilds views on every focus change which causes lag.
* Re-enable by uncommenting the native module check below.
*/ */
export function isGlassEffectAvailable(): boolean { export function isGlassEffectAvailable(): boolean {
if (!GlassPosterNativeModule) { // Glass effect disabled - using JS-based focus effects instead
return false; return false;
}
try { // Original implementation (re-enable when glass effect is optimized):
return GlassPosterNativeModule.isGlassEffectAvailable(); // if (!GlassPosterNativeModule) {
} catch { // return false;
return false; // }
} // try {
// return GlassPosterNativeModule.isGlassEffectAvailable();
// } catch {
// return false;
// }
} }
export default GlassPosterNativeModule; export default GlassPosterNativeModule;