[tvOS] ListView + Grid Landscape Poster Padding (#1213)

This commit is contained in:
Joe 2024-10-12 14:42:51 -06:00 committed by GitHub
parent 46f90bcb33
commit 498842bb84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 369 additions and 31 deletions

View File

@ -10,6 +10,7 @@ import Stinsen
import SwiftUI
final class CustomizeSettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \CustomizeSettingsCoordinator.start)
@Root
@ -17,6 +18,8 @@ final class CustomizeSettingsCoordinator: NavigationCoordinatable {
@Route(.modal)
var indicatorSettings = makeIndicatorSettings
@Route(.modal)
var listColumnSettings = makeListColumnSettings
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
@ -24,6 +27,10 @@ final class CustomizeSettingsCoordinator: NavigationCoordinatable {
}
}
func makeListColumnSettings(selection: Binding<Int>) -> some View {
ListColumnsPickerView(selection: selection)
}
@ViewBuilder
func makeStart() -> some View {
CustomizeViewsSettings()

View File

@ -82,8 +82,6 @@ final class SettingsCoordinator: NavigationCoordinatable {
@Route(.modal)
var experimentalSettings = makeExperimentalSettings
@Route(.modal)
var indicatorSettings = makeIndicatorSettings
@Route(.modal)
var log = makeLog
@Route(.modal)
var serverDetail = makeServerDetail

View File

@ -168,6 +168,8 @@ internal enum L10n {
internal static let collections = L10n.tr("Localizable", "collections", fallback: "Collections")
/// Color
internal static let color = L10n.tr("Localizable", "color", fallback: "Color")
/// Columns
internal static let columns = L10n.tr("Localizable", "columns", fallback: "Columns")
/// Coming soon
internal static let comingSoon = L10n.tr("Localizable", "comingSoon", fallback: "Coming soon")
/// Compact

View File

@ -13,6 +13,11 @@ struct StepperView<Value: CustomStringConvertible & Strideable>: View {
@Binding
private var value: Value
@State
private var updatedValue: Value
@Environment(\.presentationMode)
private var presentationMode
private var title: String
private var description: String?
private var range: ClosedRange<Value>
@ -36,7 +41,7 @@ struct StepperView<Value: CustomStringConvertible & Strideable>: View {
}
.frame(maxHeight: .infinity)
formatter(value).text
formatter(updatedValue).text
.font(.title)
.frame(height: 250)
@ -44,8 +49,10 @@ struct StepperView<Value: CustomStringConvertible & Strideable>: View {
HStack {
Button {
guard value >= range.lowerBound else { return }
value = value.advanced(by: -step)
if updatedValue > range.lowerBound {
updatedValue = max(updatedValue.advanced(by: -step), range.lowerBound)
value = updatedValue
}
} label: {
Image(systemName: "minus")
.font(.title2.weight(.bold))
@ -54,8 +61,10 @@ struct StepperView<Value: CustomStringConvertible & Strideable>: View {
.buttonStyle(.card)
Button {
guard value <= range.upperBound else { return }
value = value.advanced(by: step)
if updatedValue < range.upperBound {
updatedValue = min(updatedValue.advanced(by: step), range.upperBound)
value = updatedValue
}
} label: {
Image(systemName: "plus")
.font(.title2.weight(.bold))
@ -64,10 +73,9 @@ struct StepperView<Value: CustomStringConvertible & Strideable>: View {
.buttonStyle(.card)
}
Button {
Button(L10n.close) {
onCloseSelected()
} label: {
Text("Close")
presentationMode.wrappedValue.dismiss()
}
Spacer()
@ -86,15 +94,14 @@ extension StepperView {
range: ClosedRange<Value>,
step: Value.Stride
) {
self.init(
value: value,
title: title,
description: description,
range: range,
step: step,
formatter: { $0.description },
onCloseSelected: {}
)
self._value = value
self._updatedValue = State(initialValue: value.wrappedValue)
self.title = title
self.description = description
self.range = range
self.step = step
self.formatter = { $0.description }
self.onCloseSelected = {}
}
func valueFormatter(_ formatter: @escaping (Value) -> String) -> Self {

View File

@ -0,0 +1,165 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
import SwiftUI
extension PagingLibraryView {
struct LibraryRow: View {
@State
private var contentWidth: CGFloat = 0
@State
private var focusedItem: Element?
@FocusState
private var isFocused: Bool
private let item: Element
private var action: () -> Void
private var contextMenu: () -> any View
private let posterType: PosterDisplayType
private var onFocusChanged: ((Bool) -> Void)?
private func imageView(from element: Element) -> ImageView {
switch posterType {
case .landscape:
ImageView(element.landscapeImageSources(maxWidth: 110))
case .portrait:
ImageView(element.portraitImageSources(maxWidth: 60))
}
}
@ViewBuilder
private func itemAccessoryView(item: BaseItemDto) -> some View {
DotHStack {
if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel {
Text(seasonEpisodeLocator)
} else if let premiereYear = item.premiereDateYear {
Text(premiereYear)
}
if let runtime = item.runTimeLabel {
Text(runtime)
}
if let officialRating = item.officialRating {
Text(officialRating)
}
}
}
@ViewBuilder
private func personAccessoryView(person: BaseItemPerson) -> some View {
if let subtitle = person.subtitle {
Text(subtitle)
}
}
@ViewBuilder
private var accessoryView: some View {
switch item {
case let element as BaseItemDto:
itemAccessoryView(item: element)
case let element as BaseItemPerson:
personAccessoryView(person: element)
default:
AssertionFailureView("Used an unexpected type within a `PagingLibaryView`?")
}
}
@ViewBuilder
private var rowContent: some View {
HStack {
VStack(alignment: .leading, spacing: 5) {
Text(item.displayTitle)
.font(posterType == .landscape ? .subheadline : .callout)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)
accessoryView
.font(.caption)
.foregroundColor(Color(UIColor.lightGray))
}
Spacer()
}
}
@ViewBuilder
private var rowLeading: some View {
ZStack {
Color.clear
imageView(from: item)
.failure {
SystemImageContentView(systemName: item.systemImage)
}
}
.posterStyle(posterType)
.frame(width: posterType == .landscape ? 110 : 60)
.posterShadow()
.padding(.vertical, 8)
}
// MARK: body
var body: some View {
ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) {
rowLeading
} content: {
rowContent
}
.onSelect(perform: action)
.contextMenu(menuItems: {
contextMenu()
.eraseToAnyView()
})
.posterShadow()
.ifLet(onFocusChanged) { view, onFocusChanged in
view
.focused($isFocused)
.onChange(of: isFocused) { _, newValue in
onFocusChanged(newValue)
}
}
}
}
}
extension PagingLibraryView.LibraryRow {
init(item: Element, posterType: PosterDisplayType) {
self.init(
item: item,
action: {},
contextMenu: { EmptyView() },
posterType: posterType,
onFocusChanged: nil
)
}
}
extension PagingLibraryView.LibraryRow {
func onSelect(perform action: @escaping () -> Void) -> Self {
copy(modifying: \.action, with: action)
}
func contextMenu(@ViewBuilder perform content: @escaping () -> any View) -> Self {
copy(modifying: \.contextMenu, with: content)
}
func onFocusChanged(perform action: @escaping (Bool) -> Void) -> Self {
copy(modifying: \.onFocusChanged, with: action)
}
}

View File

@ -0,0 +1,78 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import SwiftUI
// TODO: come up with better name along with `ListRowButton`
// Meant to be used when making a custom list without `List` or `Form`
struct ListRow<Leading: View, Content: View>: View {
@State
private var contentSize: CGSize = .zero
private let leading: () -> Leading
private let content: () -> Content
private var action: () -> Void
private var insets: EdgeInsets
private var isSeparatorVisible: Bool
var body: some View {
ZStack(alignment: .bottomTrailing) {
Button {
action()
} label: {
HStack(alignment: .center, spacing: EdgeInsets.edgePadding) {
leading()
content()
.frame(maxHeight: .infinity)
.trackingSize($contentSize)
}
.padding(.top, insets.top)
.padding(.bottom, insets.bottom)
.padding(.leading, insets.leading)
.padding(.trailing, insets.trailing)
}
.foregroundStyle(.primary, .secondary)
.buttonStyle(.plain)
Color.secondarySystemFill
.frame(width: contentSize.width, height: 1)
.padding(.trailing, insets.trailing)
.visible(isSeparatorVisible)
}
}
}
extension ListRow {
init(
insets: EdgeInsets = .zero,
@ViewBuilder leading: @escaping () -> Leading,
@ViewBuilder content: @escaping () -> Content
) {
self.init(
leading: leading,
content: content,
action: {},
insets: insets,
isSeparatorVisible: true
)
}
func isSeparatorVisible(_ isVisible: Bool) -> Self {
copy(modifying: \.isSeparatorVisible, with: isVisible)
}
func onSelect(perform action: @escaping () -> Void) -> Self {
copy(modifying: \.action, with: action)
}
}

View File

@ -49,11 +49,13 @@ struct PagingLibraryView<Element: Poster>: View {
let initialPosterType = Defaults[.Customization.Library.posterType]
let initialViewType = Defaults[.Customization.Library.displayType]
let listColumnCount = Defaults[.Customization.Library.listColumnCount]
self._layout = State(
initialValue: Self.makeLayout(
posterType: initialPosterType,
viewType: initialViewType
displayType: initialViewType,
listColumnCount: listColumnCount
)
)
}
@ -90,15 +92,16 @@ struct PagingLibraryView<Element: Poster>: View {
private static func makeLayout(
posterType: PosterDisplayType,
viewType: LibraryDisplayType
displayType: LibraryDisplayType,
listColumnCount: Int
) -> CollectionVGridLayout {
switch (posterType, viewType) {
switch (posterType, displayType) {
case (.landscape, .grid):
.columns(5)
return .columns(5, insets: .init(50), itemSpacing: 50, lineSpacing: 50)
case (.portrait, .grid):
.columns(7, insets: .init(50), itemSpacing: 50, lineSpacing: 50)
return .columns(7, insets: .init(50), itemSpacing: 50, lineSpacing: 50)
case (_, .list):
.columns(1)
return .columns(listColumnCount, insets: .init(50), itemSpacing: 50, lineSpacing: 50)
}
}
@ -140,8 +143,17 @@ struct PagingLibraryView<Element: Poster>: View {
}
}
private func listItemView(item: Element) -> some View {
Button(item.displayTitle)
@ViewBuilder
private func listItemView(item: Element, posterType: PosterDisplayType) -> some View {
LibraryRow(item: item, posterType: posterType)
.onFocusChanged { newValue in
if newValue {
focusedItem = item
}
}
.onSelect {
onSelect(item)
}
}
@ViewBuilder
@ -156,7 +168,7 @@ struct PagingLibraryView<Element: Poster>: View {
case (.portrait, .grid):
portraitGridItemView(item: item)
case (_, .list):
listItemView(item: item)
listItemView(item: item, posterType: posterType)
}
}
.onReachedBottomEdge(offset: .rows(3)) {

View File

@ -0,0 +1,25 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
struct ListColumnsPickerView: View {
@Binding
var selection: Int
var body: some View {
StepperView(
title: "Columns",
value: $selection,
range: 1 ... 3,
step: 1
)
}
}

View File

@ -37,6 +37,12 @@ struct CustomizeViewsSettings: View {
private var libraryRandomImage
@Default(.Customization.Library.showFavorites)
private var showFavorites
@Default(.Customization.Library.displayType)
private var libraryDisplayType
@Default(.Customization.Library.posterType)
private var libraryPosterType
@Default(.Customization.Library.listColumnCount)
private var listColumnCount
@EnvironmentObject
private var router: CustomizeSettingsCoordinator.Router
@ -76,8 +82,6 @@ struct CustomizeViewsSettings: View {
InlineEnumToggle(title: L10n.recommended, selection: $similarPosterType)
InlineEnumToggle(title: L10n.search, selection: $searchPosterType)
InlineEnumToggle(title: L10n.library, selection: $libraryViewType)
}
Section(L10n.library) {
@ -87,6 +91,18 @@ struct CustomizeViewsSettings: View {
Toggle(L10n.randomImage, isOn: $libraryRandomImage)
Toggle(L10n.showFavorites, isOn: $showFavorites)
InlineEnumToggle(title: L10n.posters, selection: $libraryPosterType)
InlineEnumToggle(title: L10n.library, selection: $libraryDisplayType)
if libraryDisplayType == .list {
ChevronButton(
L10n.columns,
subtitle: listColumnCount.description
)
.onSelect {
router.route(to: \.listColumnSettings, $listColumnCount)
}
}
}
HomeSection()

View File

@ -73,6 +73,9 @@
4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; };
4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; };
4EE141692C8BABDF0045B661 /* ProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ProgressSection.swift */; };
4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B252CB9934700343666 /* LibraryRow.swift */; };
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; };
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; };
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; };
@ -1061,6 +1064,9 @@
4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = "<group>"; };
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; };
4EE141682C8BABDF0045B661 /* ProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSection.swift; sourceTree = "<group>"; };
4EF18B252CB9934700343666 /* LibraryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = "<group>"; };
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = "<group>"; };
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
@ -1905,6 +1911,7 @@
4E699BBD2CB34746007CBD5D /* Components */ = {
isa = PBXGroup;
children = (
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */,
4E699BBE2CB3474C007CBD5D /* Sections */,
);
path = Components;
@ -1990,6 +1997,24 @@
path = Components;
sourceTree = "<group>";
};
4EF18B232CB9932F00343666 /* PagingLibraryView */ = {
isa = PBXGroup;
children = (
4EF18B242CB9933700343666 /* Components */,
E111D8F928D0400900400001 /* PagingLibraryView.swift */,
);
path = PagingLibraryView;
sourceTree = "<group>";
};
4EF18B242CB9933700343666 /* Components */ = {
isa = PBXGroup;
children = (
4EF18B252CB9934700343666 /* LibraryRow.swift */,
4EF18B292CB993AD00343666 /* ListRow.swift */,
);
path = Components;
sourceTree = "<group>";
};
5310694F2684E7EE00CFFDBA /* VideoPlayer */ = {
isa = PBXGroup;
children = (
@ -2181,7 +2206,6 @@
E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */,
E10E842B29A589860064EA49 /* NonePosterButton.swift */,
4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */,
E111D8F928D0400900400001 /* PagingLibraryView.swift */,
E1C92617288756BD002A7A66 /* PosterButton.swift */,
E1C92619288756BD002A7A66 /* PosterHStack.swift */,
E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */,
@ -2923,6 +2947,7 @@
E10231572BCF8AF8009D71FC /* ProgramsView */,
E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */,
E1E1643928BAC2EF00323B0A /* SearchView.swift */,
4EF18B232CB9932F00343666 /* PagingLibraryView */,
E193D54A271941D300900D82 /* SelectServerView.swift */,
E164A8122BE4995200A54B18 /* SelectUserView */,
E193D54F2719430400900D82 /* ServerDetailView.swift */,
@ -4266,6 +4291,7 @@
E190704C2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */,
E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */,
E1549663296CA2EF00C4EF88 /* UserSession.swift in Sources */,
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */,
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */,
E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
@ -4476,6 +4502,7 @@
E1575E66293E77B5001665B1 /* Poster.swift in Sources */,
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */,
E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */,
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */,
E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */,
4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */,
E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */,
@ -4488,6 +4515,7 @@
E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */,
E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */,
4E2AC4CC2C6C494E00DD600D /* VideoCodec.swift in Sources */,
4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */,
E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,