mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 02:58:28 +01:00
feat(tv): native tvOS search field via SwiftUI .searchable
Add a local `tv-search` Expo module that hosts SwiftUI's `.searchable` in a UIHostingController (adapted from expo-tvos-search, minus its native results grid). It emits typed text to React Native so the existing search pipeline and custom TV results grid are reused. Handles the RN-tvOS remote gesture release needed for keyboard input on device. Wire it into TVSearchPage as a sticky header above the scrollable results, with a TVFocusGuideView bridge so focus can move from the tab bar into the native search field.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { ScrollView, TVFocusGuideView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { TvSearchView } from "@/modules/tv-search";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
@@ -22,6 +22,10 @@ import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
// Height of the native search bar itself. The tvOS grid keyboard presents as
|
||||
// its own overlay when the field is focused, so we only reserve the bar height
|
||||
// here — not the whole keyboard. Tunable once seen on device.
|
||||
const SEARCH_AREA_HEIGHT = 250;
|
||||
const SECTION_GAP = 10;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
@@ -124,7 +128,6 @@ interface TVSearchPageProps {
|
||||
}
|
||||
|
||||
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
search,
|
||||
setSearch,
|
||||
debouncedSearch,
|
||||
movies,
|
||||
@@ -157,6 +160,9 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
// Ref to the native search view, used as a TVFocusGuideView destination so
|
||||
// focus can be routed into it from the tab bar above.
|
||||
const [searchViewRef, setSearchViewRef] = useState<View | null>(null);
|
||||
|
||||
// Image URL getter for music items
|
||||
const getImageUrl = useMemo(() => {
|
||||
@@ -215,125 +221,141 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode='on-drag'
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Sticky header: search field stays pinned while results scroll below. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING + 200,
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
hasTVPreferredFocus={
|
||||
debouncedSearch.length === 0 &&
|
||||
sections.length === 0 &&
|
||||
!showDiscover
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{/* Focus bridge: routes a "down" press from the tab bar above into the
|
||||
native search view (RN-tvOS won't traverse into the native container
|
||||
on its own). */}
|
||||
{searchViewRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[searchViewRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Type Tab Badges */}
|
||||
{showDiscover && (
|
||||
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||
<TVSearchTabBadges
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
||||
module). It renders the native search bar + grid keyboard and
|
||||
forwards typed text into the existing query pipeline via setSearch;
|
||||
our own results grid renders below. */}
|
||||
<View
|
||||
style={{
|
||||
marginTop: 50,
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<TvSearchView
|
||||
ref={setSearchViewRef}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Loading State */}
|
||||
{currentLoading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
isFirstSection={index === 0}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
["artists", "albums", "songs", "playlists"].includes(
|
||||
section.key,
|
||||
)
|
||||
? getImageUrl
|
||||
: undefined
|
||||
}
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode='on-drag'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 60,
|
||||
}}
|
||||
>
|
||||
{/* Search Type Tab Badges */}
|
||||
{showDiscover && (
|
||||
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||
<TVSearchTabBadges
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Jellyseerr/Discover Search Results */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
||||
<TVJellyseerrSearchResults
|
||||
movieResults={jellyseerrMovies}
|
||||
tvResults={jellyseerrTv}
|
||||
personResults={jellyseerrPersons}
|
||||
loading={jellyseerrLoading}
|
||||
noResults={jellyseerrNoResults}
|
||||
searchQuery={debouncedSearch}
|
||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||
/>
|
||||
)}
|
||||
{/* Loading State */}
|
||||
{currentLoading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Discover Content (when no search query in Discover mode) */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
|
||||
<TVDiscover sliders={discoverSliders} />
|
||||
)}
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
isFirstSection={index === 0}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
["artists", "albums", "songs", "playlists"].includes(
|
||||
section.key,
|
||||
)
|
||||
? getImageUrl
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
{/* Jellyseerr/Discover Search Results */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
||||
<TVJellyseerrSearchResults
|
||||
movieResults={jellyseerrMovies}
|
||||
tvResults={jellyseerrTv}
|
||||
personResults={jellyseerrPersons}
|
||||
loading={jellyseerrLoading}
|
||||
noResults={jellyseerrNoResults}
|
||||
searchQuery={debouncedSearch}
|
||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Discover Content (when no search query in Discover mode) */}
|
||||
{isDiscoverMode &&
|
||||
!jellyseerrLoading &&
|
||||
debouncedSearch.length === 0 && (
|
||||
<TVDiscover sliders={discoverSliders} />
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
6
modules/tv-search/expo-module.config.json
Normal file
6
modules/tv-search/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["TvSearchModule"]
|
||||
}
|
||||
}
|
||||
2
modules/tv-search/index.ts
Normal file
2
modules/tv-search/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as TvSearchView } from "./src/TvSearchView";
|
||||
export * from "./src/TvSearchView.types";
|
||||
22
modules/tv-search/ios/TvSearch.podspec
Normal file
22
modules/tv-search/ios/TvSearch.podspec
Normal file
@@ -0,0 +1,22 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'TvSearch'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Native tvOS search field with text change events'
|
||||
s.description = 'Hosts SwiftUI .searchable inside a UIHostingController so React Native can render its own results grid while using the native tvOS search bar and grid keyboard.'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = {
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift}"
|
||||
end
|
||||
15
modules/tv-search/ios/TvSearchModule.swift
Normal file
15
modules/tv-search/ios/TvSearchModule.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class TvSearchModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("TvSearchModule")
|
||||
|
||||
View(TvSearchView.self) {
|
||||
Events("onChangeText")
|
||||
|
||||
Prop("placeholder") { (view: TvSearchView, value: String?) in
|
||||
view.setPlaceholder(value ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
206
modules/tv-search/ios/TvSearchView.swift
Normal file
206
modules/tv-search/ios/TvSearchView.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import ExpoModulesCore
|
||||
import SwiftUI
|
||||
|
||||
// React Native tvOS notification names for controlling gesture handler behavior.
|
||||
// These match the constants in RCTTVRemoteHandler.h and are what make keyboard
|
||||
// input actually reach the native search field on tvOS.
|
||||
private let RCTTVDisableGestureHandlersCancelTouchesNotification = Notification.Name(
|
||||
"RCTTVDisableGestureHandlersCancelTouchesNotification")
|
||||
private let RCTTVEnableGestureHandlersCancelTouchesNotification = Notification.Name(
|
||||
"RCTTVEnableGestureHandlersCancelTouchesNotification")
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
/// Holds the search state. ObservableObject so we can update placeholder/text
|
||||
/// without recreating the SwiftUI hierarchy.
|
||||
class TvSearchViewModel: ObservableObject {
|
||||
@Published var searchText: String = ""
|
||||
@Published var placeholder: String = "Search..."
|
||||
@Published var accentColor: Color = .white
|
||||
var onSearch: ((String) -> Void)?
|
||||
}
|
||||
|
||||
/// SwiftUI content hosting `.searchable`. This mirrors expo-tvos-search's
|
||||
/// structure — `.searchable` attached inside a `NavigationView` (REQUIRED:
|
||||
/// `.searchable` only renders a search bar in a navigation context) — but with
|
||||
/// the results grid REMOVED. The body is just transparent filler so the search
|
||||
/// field + native grid keyboard render; results are drawn by React Native
|
||||
/// below this native view instead.
|
||||
struct TvSearchContentView: View {
|
||||
@ObservedObject var viewModel: TvSearchViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
// Transparent filler gives `.searchable` something to attach to and
|
||||
// lets the native search bar/keyboard own the space.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
|
||||
.onChange(of: viewModel.searchText) { newValue in
|
||||
viewModel.onSearch?(newValue)
|
||||
}
|
||||
}
|
||||
.tint(viewModel.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
class TvSearchView: ExpoView {
|
||||
private var hostingController: UIHostingController<TvSearchContentView>?
|
||||
private let viewModel = TvSearchViewModel()
|
||||
private var gestureHandlersDisabled = false
|
||||
private var disabledGestureRecognizers: [UIGestureRecognizer] = []
|
||||
|
||||
let onChangeText = EventDispatcher()
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
hostingController?.willMove(toParent: nil)
|
||||
hostingController?.removeFromParent()
|
||||
#if !targetEnvironment(simulator)
|
||||
enableParentGestureRecognizers()
|
||||
#endif
|
||||
if gestureHandlersDisabled {
|
||||
NotificationCenter.default.post(
|
||||
name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaceholder(_ value: String) {
|
||||
viewModel.placeholder = value
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
viewModel.onSearch = { [weak self] query in
|
||||
self?.onChangeText(["text": query])
|
||||
}
|
||||
|
||||
let controller = UIHostingController(rootView: TvSearchContentView(viewModel: viewModel))
|
||||
controller.view.backgroundColor = .clear
|
||||
hostingController = controller
|
||||
|
||||
addSubview(controller.view)
|
||||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
controller.view.topAnchor.constraint(equalTo: topAnchor),
|
||||
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
if let parentVC = parentViewController() {
|
||||
parentVC.addChild(controller)
|
||||
controller.didMove(toParent: parentVC)
|
||||
}
|
||||
|
||||
// Detect when the search keyboard becomes active so we can release RN's
|
||||
// remote gesture handling (otherwise keystrokes never reach the field).
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleTextFieldDidBeginEditing),
|
||||
name: UITextField.textDidBeginEditingNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleTextFieldDidEndEditing),
|
||||
name: UITextField.textDidEndEditingNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - View controller containment
|
||||
|
||||
/// SwiftUI needs proper appearance lifecycle events for `.searchable` to
|
||||
/// register with tvOS's focus system, so we manage child VC containment as
|
||||
/// the view enters/leaves the window.
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard let controller = hostingController else { return }
|
||||
if window != nil {
|
||||
if controller.parent == nil, let parentVC = parentViewController() {
|
||||
parentVC.addChild(controller)
|
||||
controller.didMove(toParent: parentVC)
|
||||
}
|
||||
} else {
|
||||
controller.willMove(toParent: nil)
|
||||
controller.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
private func parentViewController() -> UIViewController? {
|
||||
var responder: UIResponder? = self
|
||||
while let next = responder?.next {
|
||||
if let vc = next as? UIViewController { return vc }
|
||||
responder = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Keyboard / gesture handling
|
||||
|
||||
@objc private func handleTextFieldDidBeginEditing(_ notification: Notification) {
|
||||
guard let textField = notification.object as? UITextField,
|
||||
let hostingView = hostingController?.view,
|
||||
textField.isDescendant(of: hostingView)
|
||||
else { return }
|
||||
|
||||
guard !gestureHandlersDisabled else { return }
|
||||
gestureHandlersDisabled = true
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: RCTTVDisableGestureHandlersCancelTouchesNotification, object: nil)
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
disableParentGestureRecognizers()
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc private func handleTextFieldDidEndEditing(_ notification: Notification) {
|
||||
guard let textField = notification.object as? UITextField,
|
||||
let hostingView = hostingController?.view,
|
||||
textField.isDescendant(of: hostingView)
|
||||
else { return }
|
||||
|
||||
guard gestureHandlersDisabled else { return }
|
||||
gestureHandlersDisabled = false
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
enableParentGestureRecognizers()
|
||||
#endif
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil)
|
||||
}
|
||||
|
||||
private func disableParentGestureRecognizers() {
|
||||
disabledGestureRecognizers.removeAll()
|
||||
var currentView: UIView? = superview
|
||||
while let view = currentView {
|
||||
for recognizer in view.gestureRecognizers ?? [] {
|
||||
let isTapOrPress =
|
||||
recognizer is UITapGestureRecognizer || recognizer is UILongPressGestureRecognizer
|
||||
if isTapOrPress && recognizer.isEnabled {
|
||||
recognizer.isEnabled = false
|
||||
disabledGestureRecognizers.append(recognizer)
|
||||
}
|
||||
}
|
||||
currentView = view.superview
|
||||
}
|
||||
}
|
||||
|
||||
private func enableParentGestureRecognizers() {
|
||||
for recognizer in disabledGestureRecognizers {
|
||||
recognizer.isEnabled = true
|
||||
}
|
||||
disabledGestureRecognizers.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
// Fallback for non-tvOS platforms (iOS).
|
||||
class TvSearchView: ExpoView {
|
||||
let onChangeText = EventDispatcher()
|
||||
func setPlaceholder(_ value: String) {}
|
||||
}
|
||||
|
||||
#endif
|
||||
10
modules/tv-search/package.json
Normal file
10
modules/tv-search/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "tv-search",
|
||||
"version": "0.1.0",
|
||||
"description": "Native tvOS search field (SwiftUI .searchable) emitting typed text to React Native",
|
||||
"main": "index.ts",
|
||||
"platforms": [
|
||||
"apple"
|
||||
],
|
||||
"devDependencies": {}
|
||||
}
|
||||
22
modules/tv-search/src/TvSearchView.tsx
Normal file
22
modules/tv-search/src/TvSearchView.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import type { View } from "react-native";
|
||||
|
||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||
|
||||
const NativeView: React.ComponentType<
|
||||
TvSearchViewProps & React.RefAttributes<View>
|
||||
> = requireNativeView("TvSearchModule");
|
||||
|
||||
/**
|
||||
* Forwards its ref to the underlying native view so it can be used as a
|
||||
* `TVFocusGuideView` `destinations` target for routing focus into the native
|
||||
* search bar.
|
||||
*/
|
||||
const TvSearchView = React.forwardRef<View, TvSearchViewProps>((props, ref) => {
|
||||
return <NativeView ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
TvSearchView.displayName = "TvSearchView";
|
||||
|
||||
export default TvSearchView;
|
||||
12
modules/tv-search/src/TvSearchView.types.ts
Normal file
12
modules/tv-search/src/TvSearchView.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ViewProps } from "react-native";
|
||||
|
||||
export interface TvSearchTextChangeEvent {
|
||||
nativeEvent: { text: string };
|
||||
}
|
||||
|
||||
export interface TvSearchViewProps extends ViewProps {
|
||||
/** Placeholder shown in the native search bar. */
|
||||
placeholder?: string;
|
||||
/** Fired as the user types in the native search bar. */
|
||||
onChangeText?: (event: TvSearchTextChangeEvent) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user