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:
Bosco Ho 2023-10-04 23:22:45 -07:00 committed by GitHub
parent 4bbfdcd256
commit 1bf4d9e398
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 308 additions and 183 deletions

View file

@ -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):

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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) {

View file

@ -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)

View file

@ -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))
} }
} }

View file

@ -96,6 +96,8 @@ import SwiftUI
} }
} }
var scrollToTopVisible: Bool = false
var translation: Translation? var translation: Translation?
var isLoadingTranslation = false var isLoadingTranslation = false

View file

@ -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
} }
} }

View file

@ -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
}
}
} }

View file

@ -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 {

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
}
}
} }

View file

@ -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() {}

View file

@ -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)
} }
} }
} }

View file

@ -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
}
}
} }

View file

@ -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

View file

@ -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 = [

View file

@ -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"
} }
} }
} }

View file

@ -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)

View file

@ -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)
} }
} }