From 22281aa7eb0a6e3270eea1beef0d17c055f31597 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 20 Dec 2022 09:37:07 +0100 Subject: [PATCH] Various enhancements --- IceCubesApp/App/AppRouteur.swift | 10 ++++ IceCubesApp/App/Tabs/NotificationTab.swift | 1 + IceCubesApp/App/Tabs/TimelineTab.swift | 1 + .../Account/AccountDetailHeaderView.swift | 46 +++++++++----- .../Sources/Account/AccountDetailView.swift | 10 +++- .../Account/AccountDetailViewModel.swift | 6 +- .../DesignSystem/Views/ImageSheetView.swift | 22 +++++++ .../Views/ScrollViewOffsetReader.swift | 44 ++++++++++++++ .../Routeur/Sources/Routeur/Routeur.swift | 12 ++++ .../Status/Row/StatusMediaPreviewView.swift | 1 + .../Sources/Status/Row/StatusRowView.swift | 60 +++++++++---------- 11 files changed, 164 insertions(+), 49 deletions(-) create mode 100644 Packages/DesignSystem/Sources/DesignSystem/Views/ImageSheetView.swift create mode 100644 Packages/DesignSystem/Sources/DesignSystem/Views/ScrollViewOffsetReader.swift diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index 6aad6032..61812ed1 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -3,6 +3,7 @@ import Timeline import Account import Routeur import Status +import DesignSystem extension View { func withAppRouteur() -> some View { @@ -17,4 +18,13 @@ extension View { } } } + + func withSheetDestinations(sheetDestinations: Binding) -> some View { + self.sheet(item: sheetDestinations) { destination in + switch destination { + case let .imageDetail(url): + ImageSheetView(url: url) + } + } + } } diff --git a/IceCubesApp/App/Tabs/NotificationTab.swift b/IceCubesApp/App/Tabs/NotificationTab.swift index 027ba95c..c722317d 100644 --- a/IceCubesApp/App/Tabs/NotificationTab.swift +++ b/IceCubesApp/App/Tabs/NotificationTab.swift @@ -11,6 +11,7 @@ struct NotificationsTab: View { NavigationStack(path: $routeurPath.path) { NotificationsListView() .withAppRouteur() + .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) } .environmentObject(routeurPath) } diff --git a/IceCubesApp/App/Tabs/TimelineTab.swift b/IceCubesApp/App/Tabs/TimelineTab.swift index c9d6dc21..09278959 100644 --- a/IceCubesApp/App/Tabs/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/TimelineTab.swift @@ -10,6 +10,7 @@ struct TimelineTab: View { NavigationStack(path: $routeurPath.path) { TimelineView() .withAppRouteur() + .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) } .environmentObject(routeurPath) } diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift index 55ae7bc2..58f7c7d6 100644 --- a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -1,11 +1,14 @@ import SwiftUI import Models import DesignSystem +import Routeur struct AccountDetailHeaderView: View { + @EnvironmentObject private var routeurPath: RouterPath @Environment(\.redactionReasons) private var reasons - let account: Account + let account: Account + var body: some View { VStack(alignment: .leading) { headerImageView @@ -14,21 +17,28 @@ struct AccountDetailHeaderView: View { } private var headerImageView: some View { - AsyncImage( - url: account.header, - content: { image in - image.resizable() - .aspectRatio(contentMode: .fill) - .frame(maxHeight: 200) - .clipped() - }, - placeholder: { - Color.gray - .frame(maxHeight: 20) - } - ) - .frame(maxHeight: 200) - .background(Color.gray) + GeometryReader { proxy in + AsyncImage( + url: account.header, + content: { image in + image.resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 200) + .frame(width: proxy.frame(in: .local).width) + .clipped() + }, + placeholder: { + Color.gray + .frame(height: 200) + } + ) + .background(Color.gray) + } + .frame(height: 200) + .contentShape(Rectangle()) + .onTapGesture { + routeurPath.presentedSheet = .imageDetail(url: account.header) + } } private var accountAvatarView: some View { @@ -50,6 +60,10 @@ struct AccountDetailHeaderView: View { .frame(maxWidth: 80, maxHeight: 80) } ) + .contentShape(Rectangle()) + .onTapGesture { + routeurPath.presentedSheet = .imageDetail(url: account.avatar) + } Spacer() Group { makeCustomInfoLabel(title: "Posts", count: account.statusesCount) diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index 812797b5..d8f51eae 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -8,6 +8,7 @@ import DesignSystem public struct AccountDetailView: View { @EnvironmentObject private var client: Client @StateObject private var viewModel: AccountDetailViewModel + @State private var scrollOffset: CGFloat = 0 public init(accountId: String) { _viewModel = StateObject(wrappedValue: .init(accountId: accountId)) @@ -18,18 +19,23 @@ public struct AccountDetailView: View { } public var body: some View { - ScrollView { + ScrollViewOffsetReader { offset in + self.scrollOffset = offset + } content: { LazyVStack { headerView + Divider() + .offset(y: -20) StatusesListView(fetcher: viewModel) } } - .edgesIgnoringSafeArea(.top) .task { viewModel.client = client await viewModel.fetchAccount() await viewModel.fetchStatuses() } + .edgesIgnoringSafeArea(.top) + .navigationTitle(Text(scrollOffset < -20 ? viewModel.title : "")) } @ViewBuilder diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index cc6099ba..717dd6f9 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -15,7 +15,9 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { @Published var state: State = .loading @Published var statusesState: StatusesState = .loading + @Published var title: String = "" + private var account: Account? private var statuses: [Status] = [] init(accountId: String) { @@ -30,7 +32,9 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { func fetchAccount() async { guard let client else { return } do { - state = .data(account: try await client.get(endpoint: Accounts.accounts(id: accountId))) + let account: Account = try await client.get(endpoint: Accounts.accounts(id: accountId)) + self.title = account.displayName + state = .data(account: account) } catch { state = .error(error: error) } diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/ImageSheetView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/ImageSheetView.swift new file mode 100644 index 00000000..eb841761 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/ImageSheetView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +public struct ImageSheetView: View { + let url: URL + + public init(url: URL) { + self.url = url + } + + public var body: some View { + AsyncImage( + url: url, + content: { image in + image.resizable() + .aspectRatio(contentMode: .fit) + }, + placeholder: { + ProgressView() + } + ) + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/ScrollViewOffsetReader.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/ScrollViewOffsetReader.swift new file mode 100644 index 00000000..52c470d3 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/ScrollViewOffsetReader.swift @@ -0,0 +1,44 @@ +/*! @copyright 2021 Medium */ + +import SwiftUI + +// Source: https://www.fivestars.blog/articles/scrollview-offset/ + +public struct ScrollViewOffsetReader: View { + let onOffsetChange: (CGFloat) -> Void + let content: () -> Content + + public init( + onOffsetChange: @escaping (CGFloat) -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.onOffsetChange = onOffsetChange + self.content = content + } + + public var body: some View { + ScrollView { + offsetReader + content() + .padding(.top, -8) + } + .coordinateSpace(name: "frameLayer") + .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange) + } + + var offsetReader: some View { + GeometryReader { proxy in + Color.clear + .preference( + key: OffsetPreferenceKey.self, + value: proxy.frame(in: .named("frameLayer")).minY + ) + } + .frame(height: 0) + } +} + +private struct OffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} +} diff --git a/Packages/Routeur/Sources/Routeur/Routeur.swift b/Packages/Routeur/Sources/Routeur/Routeur.swift index 95d3412f..876144b2 100644 --- a/Packages/Routeur/Sources/Routeur/Routeur.swift +++ b/Packages/Routeur/Sources/Routeur/Routeur.swift @@ -8,8 +8,20 @@ public enum RouteurDestinations: Hashable { case statusDetail(id: String) } +public enum SheetDestinations: Identifiable { + public var id: String { + switch self { + case .imageDetail: + return "imageDetail" + } + } + + case imageDetail(url: URL) +} + public class RouterPath: ObservableObject { @Published public var path: [RouteurDestinations] = [] + @Published public var presentedSheet: SheetDestinations? public init() {} diff --git a/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift b/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift index 2dd5f2bb..76459c21 100644 --- a/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift +++ b/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift @@ -66,6 +66,7 @@ public struct StatusMediaPreviewView: View { .frame(height: attachements.count > 2 ? 100 : 200) } .cornerRadius(4) + .contentShape(Rectangle()) .onTapGesture { selectedMediaSheetManager.selectedAttachement = attachement } diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index e8ebd043..21622cb3 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -39,31 +39,32 @@ public struct StatusRowView: View { } } - @ViewBuilder private var statusView: some View { - if let status: AnyStatus = status.reblog ?? status { - if !isEmbed { - Button { - routeurPath.navigate(to: .accountDetailWithAccount(account: status.account)) - } label: { - makeAccountView(status: status) - }.buttonStyle(.plain) - } - - Text(status.content.asSafeAttributedString) - .font(.body) - .onTapGesture { - routeurPath.navigate(to: .statusDetail(id: status.id)) + VStack(alignment: .leading, spacing: 8) { + if let status: AnyStatus = status.reblog ?? status { + if !isEmbed { + Button { + routeurPath.navigate(to: .accountDetailWithAccount(account: status.account)) + } label: { + makeAccountView(status: status) + }.buttonStyle(.plain) } - .environment(\.openURL, OpenURLAction { url in - routeurPath.handleStatus(status: status, url: url) - }) - - if !status.mediaAttachments.isEmpty { - StatusMediaPreviewView(attachements: status.mediaAttachments) - .padding(.vertical, 4) + + Text(status.content.asSafeAttributedString) + .font(.body) + .onTapGesture { + routeurPath.navigate(to: .statusDetail(id: status.id)) + } + .environment(\.openURL, OpenURLAction { url in + routeurPath.handleStatus(status: status, url: url) + }) + + if !status.mediaAttachments.isEmpty { + StatusMediaPreviewView(attachements: status.mediaAttachments) + .padding(.vertical, 4) + } + StatusCardView(status: status) } - StatusCardView(status: status) } } @@ -72,16 +73,15 @@ public struct StatusRowView: View { AvatarView(url: status.account.avatar) VStack(alignment: .leading) { Text(status.account.displayName) - .font(.headline) - HStack { - Text("@\(status.account.acct)") - .font(.footnote) - .foregroundColor(.gray) - Spacer() + .font(.subheadline) + .fontWeight(.semibold) + Group { + Text("@\(status.account.acct)") + + Text(" βΈ± ") + Text(status.createdAt.formatted) - .font(.footnote) - .foregroundColor(.gray) } + .font(.footnote) + .foregroundColor(.gray) } } }