mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-25 09:41:02 +00:00
Feature: Tab bar scroll to top (#1598)
* - *WIP* Explore tab: Tap on tab to scroll to top. * - Explore tab: Tap tab to scroll to top. * - Explore: Tap tab again to focus on search bar. - Explore: Set `.defaultMinListRowHeight` so scroll to view doesn't occupy more than 1pt height in grouped style list. - Explore: Add padding to get Explore list view to look the same. * - Explore: Minor adjust to padding. * - Messages: Add tap tab to scroll to top. * - Notifications: Add tap tab to scroll to top. * - Profile: Add tap tab to scroll to top. * Add `ScrollToView` that can be used across all views. * Move scroll-to-top constants to ScrollToView. * Format --------- Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
This commit is contained in:
parent
4bbfdcd256
commit
1bf4d9e398
22 changed files with 308 additions and 183 deletions
|
@ -17,9 +17,9 @@ extension View {
|
||||||
navigationDestination(for: RouterDestination.self) { destination in
|
navigationDestination(for: RouterDestination.self) { destination in
|
||||||
switch destination {
|
switch destination {
|
||||||
case let .accountDetail(id):
|
case let .accountDetail(id):
|
||||||
AccountDetailView(accountId: id)
|
AccountDetailView(accountId: id, scrollToTopSignal: .constant(0))
|
||||||
case let .accountDetailWithAccount(account):
|
case let .accountDetailWithAccount(account):
|
||||||
AccountDetailView(account: account)
|
AccountDetailView(account: account, scrollToTopSignal: .constant(0))
|
||||||
case let .accountSettingsWithAccount(account, appAccount):
|
case let .accountSettingsWithAccount(account, appAccount):
|
||||||
AccountSettingsView(account: account, appAccount: appAccount)
|
AccountSettingsView(account: account, appAccount: appAccount)
|
||||||
case let .statusDetail(id):
|
case let .statusDetail(id):
|
||||||
|
|
|
@ -14,11 +14,12 @@ struct ExploreTab: View {
|
||||||
@Environment(CurrentAccount.self) private var currentAccount
|
@Environment(CurrentAccount.self) private var currentAccount
|
||||||
@Environment(Client.self) private var client
|
@Environment(Client.self) private var client
|
||||||
@State private var routerPath = RouterPath()
|
@State private var routerPath = RouterPath()
|
||||||
|
@State private var scrollToTopSignal: Int = 0
|
||||||
@Binding var popToRootTab: Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $routerPath.path) {
|
NavigationStack(path: $routerPath.path) {
|
||||||
ExploreView()
|
ExploreView(scrollToTopSignal: $scrollToTopSignal)
|
||||||
.withAppRouter()
|
.withAppRouter()
|
||||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||||
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
|
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
|
||||||
|
@ -39,7 +40,11 @@ struct ExploreTab: View {
|
||||||
.environment(routerPath)
|
.environment(routerPath)
|
||||||
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
||||||
if newValue == .explore {
|
if newValue == .explore {
|
||||||
routerPath.path = []
|
if routerPath.path.isEmpty {
|
||||||
|
scrollToTopSignal += 1
|
||||||
|
} else {
|
||||||
|
routerPath.path = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: client.id) {
|
.onChange(of: client.id) {
|
||||||
|
|
|
@ -16,11 +16,12 @@ struct MessagesTab: View {
|
||||||
@Environment(CurrentAccount.self) private var currentAccount
|
@Environment(CurrentAccount.self) private var currentAccount
|
||||||
@Environment(AppAccountsManager.self) private var appAccount
|
@Environment(AppAccountsManager.self) private var appAccount
|
||||||
@State private var routerPath = RouterPath()
|
@State private var routerPath = RouterPath()
|
||||||
|
@State private var scrollToTopSignal: Int = 0
|
||||||
@Binding var popToRootTab: Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $routerPath.path) {
|
NavigationStack(path: $routerPath.path) {
|
||||||
ConversationsListView()
|
ConversationsListView(scrollToTopSignal: $scrollToTopSignal)
|
||||||
.withAppRouter()
|
.withAppRouter()
|
||||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
@ -35,7 +36,11 @@ struct MessagesTab: View {
|
||||||
}
|
}
|
||||||
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
||||||
if newValue == .messages {
|
if newValue == .messages {
|
||||||
routerPath.path = []
|
if routerPath.path.isEmpty {
|
||||||
|
scrollToTopSignal += 1
|
||||||
|
} else {
|
||||||
|
routerPath.path = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: client.id) {
|
.onChange(of: client.id) {
|
||||||
|
|
|
@ -20,13 +20,14 @@ struct NotificationsTab: View {
|
||||||
@Environment(UserPreferences.self) private var userPreferences
|
@Environment(UserPreferences.self) private var userPreferences
|
||||||
@Environment(PushNotificationsService.self) private var pushNotificationsService
|
@Environment(PushNotificationsService.self) private var pushNotificationsService
|
||||||
@State private var routerPath = RouterPath()
|
@State private var routerPath = RouterPath()
|
||||||
|
@State private var scrollToTopSignal: Int = 0
|
||||||
@Binding var popToRootTab: Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
let lockedType: Models.Notification.NotificationType?
|
let lockedType: Models.Notification.NotificationType?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $routerPath.path) {
|
NavigationStack(path: $routerPath.path) {
|
||||||
NotificationsListView(lockedType: lockedType)
|
NotificationsListView(lockedType: lockedType, scrollToTopSignal: $scrollToTopSignal)
|
||||||
.withAppRouter()
|
.withAppRouter()
|
||||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
@ -58,7 +59,11 @@ struct NotificationsTab: View {
|
||||||
.environment(routerPath)
|
.environment(routerPath)
|
||||||
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
||||||
if newValue == .notifications {
|
if newValue == .notifications {
|
||||||
routerPath.path = []
|
if routerPath.path.isEmpty {
|
||||||
|
scrollToTopSignal += 1
|
||||||
|
} else {
|
||||||
|
routerPath.path = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
||||||
|
|
|
@ -15,25 +15,30 @@ struct ProfileTab: View {
|
||||||
@Environment(Client.self) private var client
|
@Environment(Client.self) private var client
|
||||||
@Environment(CurrentAccount.self) private var currentAccount
|
@Environment(CurrentAccount.self) private var currentAccount
|
||||||
@State private var routerPath = RouterPath()
|
@State private var routerPath = RouterPath()
|
||||||
|
@State private var scrollToTopSignal: Int = 0
|
||||||
@Binding var popToRootTab: Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $routerPath.path) {
|
NavigationStack(path: $routerPath.path) {
|
||||||
if let account = currentAccount.account {
|
if let account = currentAccount.account {
|
||||||
AccountDetailView(account: account)
|
AccountDetailView(account: account, scrollToTopSignal: $scrollToTopSignal)
|
||||||
.withAppRouter()
|
.withAppRouter()
|
||||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||||
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
|
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
|
||||||
.id(account.id)
|
.id(account.id)
|
||||||
} else {
|
} else {
|
||||||
AccountDetailView(account: .placeholder())
|
AccountDetailView(account: .placeholder(), scrollToTopSignal: $scrollToTopSignal)
|
||||||
.redacted(reason: .placeholder)
|
.redacted(reason: .placeholder)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
.onChange(of: $popToRootTab.wrappedValue) { _, newValue in
|
||||||
if newValue == .profile {
|
if newValue == .profile {
|
||||||
routerPath.path = []
|
if routerPath.path.isEmpty {
|
||||||
|
scrollToTopSignal += 1
|
||||||
|
} else {
|
||||||
|
routerPath.path = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: client.id) {
|
.onChange(of: client.id) {
|
||||||
|
|
|
@ -146,8 +146,7 @@ public struct AccountDetailContextMenu: View {
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
|
|
||||||
if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier
|
if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier {
|
||||||
{
|
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.translate(userLang: lang)
|
await viewModel.translate(userLang: lang)
|
||||||
|
|
|
@ -30,14 +30,18 @@ public struct AccountDetailView: View {
|
||||||
@State private var isEditingFilters: Bool = false
|
@State private var isEditingFilters: Bool = false
|
||||||
@State private var isEditingRelationshipNote: Bool = false
|
@State private var isEditingRelationshipNote: Bool = false
|
||||||
|
|
||||||
|
@Binding var scrollToTopSignal: Int
|
||||||
|
|
||||||
/// When coming from a URL like a mention tap in a status.
|
/// When coming from a URL like a mention tap in a status.
|
||||||
public init(accountId: String) {
|
public init(accountId: String, scrollToTopSignal: Binding<Int>) {
|
||||||
_viewModel = .init(initialValue: .init(accountId: accountId))
|
_viewModel = .init(initialValue: .init(accountId: accountId))
|
||||||
|
_scrollToTopSignal = scrollToTopSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When the account is already fetched by the parent caller.
|
/// When the account is already fetched by the parent caller.
|
||||||
public init(account: Account) {
|
public init(account: Account, scrollToTopSignal: Binding<Int>) {
|
||||||
_viewModel = .init(initialValue: .init(account: account))
|
_viewModel = .init(initialValue: .init(account: account))
|
||||||
|
_scrollToTopSignal = scrollToTopSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
|
@ -46,6 +50,7 @@ public struct AccountDetailView: View {
|
||||||
makeHeaderView(proxy: proxy)
|
makeHeaderView(proxy: proxy)
|
||||||
.applyAccountDetailsRowStyle(theme: theme)
|
.applyAccountDetailsRowStyle(theme: theme)
|
||||||
.padding(.bottom, -20)
|
.padding(.bottom, -20)
|
||||||
|
.id(ScrollToView.Constants.scrollToTop)
|
||||||
familiarFollowers
|
familiarFollowers
|
||||||
.applyAccountDetailsRowStyle(theme: theme)
|
.applyAccountDetailsRowStyle(theme: theme)
|
||||||
featuredTagsView
|
featuredTagsView
|
||||||
|
@ -83,6 +88,11 @@ public struct AccountDetailView: View {
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(theme.primaryBackgroundColor)
|
.background(theme.primaryBackgroundColor)
|
||||||
|
.onChange(of: scrollToTopSignal) {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard reasons != .placeholder else { return }
|
guard reasons != .placeholder else { return }
|
||||||
|
@ -418,6 +428,6 @@ extension View {
|
||||||
|
|
||||||
struct AccountDetailView_Previews: PreviewProvider {
|
struct AccountDetailView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
AccountDetailView(account: .placeholder())
|
AccountDetailView(account: .placeholder(), scrollToTopSignal: .constant(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,6 +96,8 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var scrollToTopVisible: Bool = false
|
||||||
|
|
||||||
var translation: Translation?
|
var translation: Translation?
|
||||||
var isLoadingTranslation = false
|
var isLoadingTranslation = false
|
||||||
|
|
||||||
|
|
|
@ -96,9 +96,9 @@ public struct AppAccountsSelectorView: View {
|
||||||
|
|
||||||
private var accountBackgroundColor: Color {
|
private var accountBackgroundColor: Color {
|
||||||
if #available(iOS 16.4, *) {
|
if #available(iOS 16.4, *) {
|
||||||
return Color.clear
|
Color.clear
|
||||||
} else {
|
} else {
|
||||||
return theme.secondaryBackgroundColor
|
theme.secondaryBackgroundColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,11 @@ public struct ConversationsListView: View {
|
||||||
|
|
||||||
@State private var viewModel = ConversationsListViewModel()
|
@State private var viewModel = ConversationsListViewModel()
|
||||||
|
|
||||||
public init() {}
|
@Binding var scrollToTopSignal: Int
|
||||||
|
|
||||||
|
public init(scrollToTopSignal: Binding<Int>) {
|
||||||
|
_scrollToTopSignal = scrollToTopSignal
|
||||||
|
}
|
||||||
|
|
||||||
private var conversations: Binding<[Conversation]> {
|
private var conversations: Binding<[Conversation]> {
|
||||||
if viewModel.isLoadingFirstPage {
|
if viewModel.isLoadingFirstPage {
|
||||||
|
@ -26,88 +30,107 @@ public struct ConversationsListView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ScrollView {
|
ScrollViewReader { proxy in
|
||||||
LazyVStack {
|
ScrollView {
|
||||||
Group {
|
scrollToTopView
|
||||||
if !conversations.isEmpty || viewModel.isLoadingFirstPage {
|
LazyVStack {
|
||||||
ForEach(conversations) { $conversation in
|
Group {
|
||||||
if viewModel.isLoadingFirstPage {
|
if !conversations.isEmpty || viewModel.isLoadingFirstPage {
|
||||||
ConversationsListRow(conversation: $conversation, viewModel: viewModel)
|
ForEach(conversations) { $conversation in
|
||||||
.padding(.horizontal, .layoutPadding)
|
if viewModel.isLoadingFirstPage {
|
||||||
.redacted(reason: .placeholder)
|
ConversationsListRow(conversation: $conversation, viewModel: viewModel)
|
||||||
.allowsHitTesting(false)
|
.padding(.horizontal, .layoutPadding)
|
||||||
} else {
|
.redacted(reason: .placeholder)
|
||||||
ConversationsListRow(conversation: $conversation, viewModel: viewModel)
|
.allowsHitTesting(false)
|
||||||
.padding(.horizontal, .layoutPadding)
|
} else {
|
||||||
|
ConversationsListRow(conversation: $conversation, viewModel: viewModel)
|
||||||
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
Divider()
|
} else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError {
|
||||||
}
|
EmptyView(iconName: "tray",
|
||||||
} else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError {
|
title: "conversations.empty.title",
|
||||||
EmptyView(iconName: "tray",
|
message: "conversations.empty.message")
|
||||||
title: "conversations.empty.title",
|
} else if viewModel.isError {
|
||||||
message: "conversations.empty.message")
|
ErrorView(title: "conversations.error.title",
|
||||||
} else if viewModel.isError {
|
message: "conversations.error.message",
|
||||||
ErrorView(title: "conversations.error.title",
|
buttonTitle: "conversations.error.button")
|
||||||
message: "conversations.error.message",
|
{
|
||||||
buttonTitle: "conversations.error.button")
|
|
||||||
{
|
|
||||||
Task {
|
|
||||||
await viewModel.fetchConversations()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.nextPage != nil {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
if !viewModel.isLoadingNextPage {
|
|
||||||
Task {
|
Task {
|
||||||
await viewModel.fetchNextPage()
|
await viewModel.fetchConversations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.nextPage != nil {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !viewModel.isLoadingNextPage {
|
||||||
|
Task {
|
||||||
|
await viewModel.fetchNextPage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, .layoutPadding)
|
||||||
}
|
}
|
||||||
.padding(.top, .layoutPadding)
|
.scrollContentBackground(.hidden)
|
||||||
}
|
.background(theme.primaryBackgroundColor)
|
||||||
.scrollContentBackground(.hidden)
|
.navigationTitle("conversations.navigation-title")
|
||||||
.background(theme.primaryBackgroundColor)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("conversations.navigation-title")
|
.toolbar {
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
StatusEditorToolbarItem(visibility: .direct)
|
||||||
.toolbar {
|
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn {
|
||||||
StatusEditorToolbarItem(visibility: .direct)
|
SecondaryColumnToolbarItem()
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn {
|
}
|
||||||
SecondaryColumnToolbarItem()
|
|
||||||
}
|
}
|
||||||
}
|
.onChange(of: watcher.latestEvent?.id) {
|
||||||
.onChange(of: watcher.latestEvent?.id) {
|
if let latestEvent = watcher.latestEvent {
|
||||||
if let latestEvent = watcher.latestEvent {
|
viewModel.handleEvent(event: latestEvent)
|
||||||
viewModel.handleEvent(event: latestEvent)
|
}
|
||||||
}
|
}
|
||||||
}
|
.onChange(of: scrollToTopSignal) {
|
||||||
.refreshable {
|
withAnimation {
|
||||||
// note: this Task wrapper should not be necessary, but it reportedly crashes without it
|
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
|
||||||
// when refreshing on an empty list
|
}
|
||||||
Task {
|
|
||||||
SoundEffectManager.shared.playSound(of: .pull)
|
|
||||||
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
|
|
||||||
await viewModel.fetchConversations()
|
|
||||||
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
|
|
||||||
SoundEffectManager.shared.playSound(of: .refresh)
|
|
||||||
}
|
}
|
||||||
}
|
.refreshable {
|
||||||
.onAppear {
|
// note: this Task wrapper should not be necessary, but it reportedly crashes without it
|
||||||
viewModel.client = client
|
// when refreshing on an empty list
|
||||||
if client.isAuth {
|
|
||||||
Task {
|
Task {
|
||||||
|
SoundEffectManager.shared.playSound(of: .pull)
|
||||||
|
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
|
||||||
await viewModel.fetchConversations()
|
await viewModel.fetchConversations()
|
||||||
|
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
|
||||||
|
SoundEffectManager.shared.playSound(of: .refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewModel.client = client
|
||||||
|
if client.isAuth {
|
||||||
|
Task {
|
||||||
|
await viewModel.fetchConversations()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var scrollToTopView: some View {
|
||||||
|
ScrollToView()
|
||||||
|
.frame(height: .scrollToViewHeight)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.scrollToTopVisible = true
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
viewModel.scrollToTopVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,8 @@ import SwiftUI
|
||||||
|
|
||||||
var nextPage: LinkHandler?
|
var nextPage: LinkHandler?
|
||||||
|
|
||||||
|
var scrollToTopVisible: Bool = false
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
func fetchConversations() async {
|
func fetchConversations() async {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Foundation
|
||||||
public extension CGFloat {
|
public extension CGFloat {
|
||||||
static let layoutPadding: CGFloat = 20
|
static let layoutPadding: CGFloat = 20
|
||||||
static let dividerPadding: CGFloat = 2
|
static let dividerPadding: CGFloat = 2
|
||||||
|
static let scrollToViewHeight: CGFloat = 1
|
||||||
static let statusColumnsSpacing: CGFloat = 8
|
static let statusColumnsSpacing: CGFloat = 8
|
||||||
static let secondaryColumnWidth: CGFloat = 400
|
static let secondaryColumnWidth: CGFloat = 400
|
||||||
static let sidebarWidth: CGFloat = 80
|
static let sidebarWidth: CGFloat = 80
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Add to any `ScrollView` or `List` to enable scroll-to behaviour (e.g. useful for scroll-to-top).
|
||||||
|
///
|
||||||
|
/// This view is configured such that `.onAppear` and `.onDisappear` are called while remaining invisible to users on-screen.
|
||||||
|
public struct ScrollToView: View {
|
||||||
|
public enum Constants {
|
||||||
|
public static let scrollToTop = "top"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack { SwiftUI.EmptyView() }
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(.init())
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
.id(Constants.scrollToTop)
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,86 +15,108 @@ public struct ExploreView: View {
|
||||||
|
|
||||||
@State private var viewModel = ExploreViewModel()
|
@State private var viewModel = ExploreViewModel()
|
||||||
|
|
||||||
public init() {}
|
@Binding var scrollToTopSignal: Int
|
||||||
|
|
||||||
|
public init(scrollToTopSignal: Binding<Int>) {
|
||||||
|
_scrollToTopSignal = scrollToTopSignal
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
List {
|
ScrollViewReader { proxy in
|
||||||
if !viewModel.isLoaded {
|
List {
|
||||||
quickAccessView
|
scrollToTopView
|
||||||
loadingView
|
.padding(.bottom, 4)
|
||||||
} else if !viewModel.searchQuery.isEmpty {
|
if !viewModel.isLoaded {
|
||||||
if let results = viewModel.results[viewModel.searchQuery] {
|
quickAccessView
|
||||||
if results.isEmpty, !viewModel.isSearching {
|
.padding(.bottom, 5)
|
||||||
EmptyView(iconName: "magnifyingglass",
|
loadingView
|
||||||
title: "explore.search.empty.title",
|
} else if !viewModel.searchQuery.isEmpty {
|
||||||
message: "explore.search.empty.message")
|
if let results = viewModel.results[viewModel.searchQuery] {
|
||||||
.listRowBackground(theme.secondaryBackgroundColor)
|
if results.isEmpty, !viewModel.isSearching {
|
||||||
.listRowSeparator(.hidden)
|
EmptyView(iconName: "magnifyingglass",
|
||||||
|
title: "explore.search.empty.title",
|
||||||
|
message: "explore.search.empty.message")
|
||||||
|
.listRowBackground(theme.secondaryBackgroundColor)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
} else {
|
||||||
|
makeSearchResultsView(results: results)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
makeSearchResultsView(results: results)
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.listRowBackground(theme.secondaryBackgroundColor)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.id(UUID())
|
||||||
}
|
}
|
||||||
|
} else if viewModel.allSectionsEmpty {
|
||||||
|
EmptyView(iconName: "magnifyingglass",
|
||||||
|
title: "explore.search.title",
|
||||||
|
message: "explore.search.message-\(client.server)")
|
||||||
|
.listRowBackground(theme.secondaryBackgroundColor)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
} else {
|
} else {
|
||||||
HStack {
|
quickAccessView
|
||||||
Spacer()
|
.padding(.bottom, 4)
|
||||||
ProgressView()
|
|
||||||
Spacer()
|
if !viewModel.trendingTags.isEmpty {
|
||||||
|
trendingTagsSection
|
||||||
|
}
|
||||||
|
if !viewModel.suggestedAccounts.isEmpty {
|
||||||
|
suggestedAccountsSection
|
||||||
|
}
|
||||||
|
if !viewModel.trendingStatuses.isEmpty {
|
||||||
|
trendingPostsSection
|
||||||
|
}
|
||||||
|
if !viewModel.trendingLinks.isEmpty {
|
||||||
|
trendingLinksSection
|
||||||
}
|
}
|
||||||
.listRowBackground(theme.secondaryBackgroundColor)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.id(UUID())
|
|
||||||
}
|
|
||||||
} else if viewModel.allSectionsEmpty {
|
|
||||||
EmptyView(iconName: "magnifyingglass",
|
|
||||||
title: "explore.search.title",
|
|
||||||
message: "explore.search.message-\(client.server)")
|
|
||||||
.listRowBackground(theme.secondaryBackgroundColor)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
} else {
|
|
||||||
quickAccessView
|
|
||||||
if !viewModel.trendingTags.isEmpty {
|
|
||||||
trendingTagsSection
|
|
||||||
}
|
|
||||||
if !viewModel.suggestedAccounts.isEmpty {
|
|
||||||
suggestedAccountsSection
|
|
||||||
}
|
|
||||||
if !viewModel.trendingStatuses.isEmpty {
|
|
||||||
trendingPostsSection
|
|
||||||
}
|
|
||||||
if !viewModel.trendingLinks.isEmpty {
|
|
||||||
trendingLinksSection
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.environment(\.defaultMinListRowHeight, .scrollToViewHeight)
|
||||||
.task {
|
.task {
|
||||||
viewModel.client = client
|
viewModel.client = client
|
||||||
await viewModel.fetchTrending()
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
Task {
|
|
||||||
SoundEffectManager.shared.playSound(of: .pull)
|
|
||||||
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
|
|
||||||
await viewModel.fetchTrending()
|
await viewModel.fetchTrending()
|
||||||
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
|
|
||||||
SoundEffectManager.shared.playSound(of: .refresh)
|
|
||||||
}
|
}
|
||||||
}
|
.refreshable {
|
||||||
.listStyle(.grouped)
|
Task {
|
||||||
.scrollContentBackground(.hidden)
|
SoundEffectManager.shared.playSound(of: .pull)
|
||||||
.background(theme.secondaryBackgroundColor)
|
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
|
||||||
.navigationTitle("explore.navigation-title")
|
await viewModel.fetchTrending()
|
||||||
.searchable(text: $viewModel.searchQuery,
|
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
|
||||||
placement: .navigationBarDrawer(displayMode: .always),
|
SoundEffectManager.shared.playSound(of: .refresh)
|
||||||
prompt: Text("explore.search.prompt"))
|
}
|
||||||
.searchScopes($viewModel.searchScope) {
|
}
|
||||||
ForEach(ExploreViewModel.SearchScope.allCases, id: \.self) { scope in
|
.listStyle(.grouped)
|
||||||
Text(scope.localizedString)
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(theme.secondaryBackgroundColor)
|
||||||
|
.navigationTitle("explore.navigation-title")
|
||||||
|
.searchable(text: $viewModel.searchQuery,
|
||||||
|
isPresented: $viewModel.isSearchPresented,
|
||||||
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
|
prompt: Text("explore.search.prompt"))
|
||||||
|
.searchScopes($viewModel.searchScope) {
|
||||||
|
ForEach(ExploreViewModel.SearchScope.allCases, id: \.self) { scope in
|
||||||
|
Text(scope.localizedString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: viewModel.searchQuery) {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
await viewModel.search()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
.onChange(of: scrollToTopSignal) {
|
||||||
|
if viewModel.scrollToTopVisible {
|
||||||
|
viewModel.isSearchPresented.toggle()
|
||||||
|
} else {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.task(id: viewModel.searchQuery) {
|
|
||||||
do {
|
|
||||||
try await Task.sleep(for: .milliseconds(150))
|
|
||||||
await viewModel.search()
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -234,4 +256,15 @@ public struct ExploreView: View {
|
||||||
.listRowBackground(theme.primaryBackgroundColor)
|
.listRowBackground(theme.primaryBackgroundColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var scrollToTopView: some View {
|
||||||
|
ScrollToView()
|
||||||
|
.frame(height: .scrollToViewHeight)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.scrollToTopVisible = true
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
viewModel.scrollToTopVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,8 @@ import SwiftUI
|
||||||
var trendingStatuses: [Status] = []
|
var trendingStatuses: [Status] = []
|
||||||
var trendingLinks: [Card] = []
|
var trendingLinks: [Card] = []
|
||||||
var searchScope: SearchScope = .all
|
var searchScope: SearchScope = .all
|
||||||
|
var scrollToTopVisible: Bool = false
|
||||||
|
var isSearchPresented: Bool = false
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
|
|
|
@ -83,9 +83,9 @@ import SwiftUI
|
||||||
if let rootHost = host.split(separator: ".", maxSplits: 1).last {
|
if let rootHost = host.split(separator: ".", maxSplits: 1).last {
|
||||||
// Sometimes the connection is with the root host instead of a subdomain
|
// Sometimes the connection is with the root host instead of a subdomain
|
||||||
// eg. Mastodon runs on mastdon.domain.com but the connection is with domain.com
|
// eg. Mastodon runs on mastdon.domain.com but the connection is with domain.com
|
||||||
return $0.connections.contains(host) || $0.connections.contains(String(rootHost))
|
$0.connections.contains(host) || $0.connections.contains(String(rootHost))
|
||||||
} else {
|
} else {
|
||||||
return $0.connections.contains(host)
|
$0.connections.contains(host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,21 +14,31 @@ public struct NotificationsListView: View {
|
||||||
@Environment(RouterPath.self) private var routerPath
|
@Environment(RouterPath.self) private var routerPath
|
||||||
@Environment(CurrentAccount.self) private var account
|
@Environment(CurrentAccount.self) private var account
|
||||||
@State private var viewModel = NotificationsViewModel()
|
@State private var viewModel = NotificationsViewModel()
|
||||||
|
@Binding var scrollToTopSignal: Int
|
||||||
|
|
||||||
let lockedType: Models.Notification.NotificationType?
|
let lockedType: Models.Notification.NotificationType?
|
||||||
|
|
||||||
public init(lockedType: Models.Notification.NotificationType?) {
|
public init(lockedType: Models.Notification.NotificationType?, scrollToTopSignal: Binding<Int>) {
|
||||||
self.lockedType = lockedType
|
self.lockedType = lockedType
|
||||||
|
_scrollToTopSignal = scrollToTopSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
List {
|
ScrollViewReader { proxy in
|
||||||
topPaddingView
|
List {
|
||||||
notificationsView
|
scrollToTopView
|
||||||
|
topPaddingView
|
||||||
|
notificationsView
|
||||||
|
}
|
||||||
|
.id(account.account?.id)
|
||||||
|
.environment(\.defaultMinListRowHeight, 1)
|
||||||
|
.listStyle(.plain)
|
||||||
|
.onChange(of: scrollToTopSignal) {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.id(account.account?.id)
|
|
||||||
.environment(\.defaultMinListRowHeight, 1)
|
|
||||||
.listStyle(.plain)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
let title = lockedType?.menuTitle() ?? viewModel.selectedType?.menuTitle() ?? "notifications.navigation-title"
|
let title = lockedType?.menuTitle() ?? viewModel.selectedType?.menuTitle() ?? "notifications.navigation-title"
|
||||||
|
@ -196,4 +206,15 @@ public struct NotificationsListView: View {
|
||||||
.frame(height: .layoutPadding)
|
.frame(height: .layoutPadding)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var scrollToTopView: some View {
|
||||||
|
ScrollToView()
|
||||||
|
.frame(height: .scrollToViewHeight)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.scrollToTopVisible = true
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
viewModel.scrollToTopVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,8 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var scrollToTopVisible: Bool = false
|
||||||
|
|
||||||
private var queryTypes: [String]? {
|
private var queryTypes: [String]? {
|
||||||
if let selectedType {
|
if let selectedType {
|
||||||
var excludedTypes = Models.Notification.NotificationType.allCases
|
var excludedTypes = Models.Notification.NotificationType.allCases
|
||||||
|
|
|
@ -15,11 +15,10 @@ actor StatusEditorCompressor {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxPixelSize: Int
|
let maxPixelSize: Int = if Bundle.main.bundlePath.hasSuffix(".appex") {
|
||||||
if Bundle.main.bundlePath.hasSuffix(".appex") {
|
1536
|
||||||
maxPixelSize = 1536
|
|
||||||
} else {
|
} else {
|
||||||
maxPixelSize = 4096
|
4096
|
||||||
}
|
}
|
||||||
|
|
||||||
let downsampleOptions = [
|
let downsampleOptions = [
|
||||||
|
|
|
@ -188,9 +188,9 @@ private func localURLFor(received: ReceivedTransferredFile) -> URL {
|
||||||
public extension URL {
|
public extension URL {
|
||||||
func mimeType() -> String {
|
func mimeType() -> String {
|
||||||
if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType {
|
if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType {
|
||||||
return mimeType
|
mimeType
|
||||||
} else {
|
} else {
|
||||||
return "application/octet-stream"
|
"application/octet-stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -128,8 +128,7 @@ struct StatusRowContextMenu: View {
|
||||||
Label("status.action.copy-link", systemImage: "link")
|
Label("status.action.copy-link", systemImage: "link")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier
|
if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier {
|
||||||
{
|
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.translate(userLang: lang)
|
await viewModel.translate(userLang: lang)
|
||||||
|
|
|
@ -9,10 +9,6 @@ import SwiftUIIntrospect
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct TimelineView: View {
|
public struct TimelineView: View {
|
||||||
private enum Constants {
|
|
||||||
static let scrollToTop = "top"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@Environment(Theme.self) private var theme
|
@Environment(Theme.self) private var theme
|
||||||
@Environment(CurrentAccount.self) private var account
|
@Environment(CurrentAccount.self) private var account
|
||||||
|
@ -87,7 +83,7 @@ public struct TimelineView: View {
|
||||||
}
|
}
|
||||||
.onChange(of: scrollToTopSignal) {
|
.onChange(of: scrollToTopSignal) {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(Constants.scrollToTop, anchor: .top)
|
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -258,18 +254,13 @@ public struct TimelineView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var scrollToTopView: some View {
|
private var scrollToTopView: some View {
|
||||||
HStack { EmptyView() }
|
ScrollToView()
|
||||||
.listRowBackground(theme.primaryBackgroundColor)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowInsets(.init())
|
|
||||||
.frame(height: .layoutPadding)
|
.frame(height: .layoutPadding)
|
||||||
.id(Constants.scrollToTop)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.scrollToTopVisible = true
|
viewModel.scrollToTopVisible = true
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
viewModel.scrollToTopVisible = false
|
viewModel.scrollToTopVisible = false
|
||||||
}
|
}
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue