Feature: popover the account overview when hovering on the avatar (#1682)

* fix avatar scale

* refactor avatar  config data

* add `AvatarView_Previews`

* refactor shape and placeholder of avatar

* refactor `AvatarView` and add `AvatarPopup`

* add `hoverEffect` for iPad

* fix auto-dismiss bug

* fix `showPopup` bug

* disable inappropriate avatar popups
This commit is contained in:
Thai D. V 2023-11-20 16:59:49 +07:00 committed by GitHub
parent 534b098ca6
commit 94172cef27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 317 additions and 106 deletions

View file

@ -105,7 +105,7 @@ struct AccountSettingsView: View {
.toolbar {
ToolbarItem(placement: .principal) {
HStack {
AvatarView(url: account.avatar, size: .embed)
AvatarView(account: account, config: .embed)
Text(account.safeDisplayName)
.font(.headline)
}

View file

@ -99,7 +99,7 @@ struct AccountDetailHeaderView: View {
private var accountAvatarView: some View {
HStack {
ZStack(alignment: .topTrailing) {
AvatarView(url: account.avatar, size: .account)
AvatarView(account: account, config: .account)
.accessibilityLabel("accessibility.tabs.profile.user-avatar.label")
if viewModel.isCurrentUser, isSupporter {
Image(systemName: "checkmark.seal.fill")

View file

@ -214,7 +214,7 @@ public struct AccountDetailView: View {
Button {
routerPath.navigate(to: .accountDetailWithAccount(account: account))
} label: {
AvatarView(url: account.avatar, size: .badge)
AvatarView(account: account, config: .badge)
.padding(.leading, -4)
.accessibilityLabel(account.safeDisplayName)

View file

@ -44,7 +44,7 @@ public struct AccountsListRow: View {
public var body: some View {
HStack(alignment: .top) {
AvatarView(url: viewModel.account.avatar, size: .status)
AvatarView(account: viewModel.account, config: .status)
VStack(alignment: .leading, spacing: 2) {
EmojiTextApp(.init(stringValue: viewModel.account.safeDisplayName), emojis: viewModel.account.emojis)
.font(.scaledSubheadline)

View file

@ -35,7 +35,7 @@ public struct AppAccountView: View {
private var compactView: some View {
HStack {
if let account = viewModel.account {
AvatarView(url: account.avatar)
AvatarView(account: account)
} else {
ProgressView()
}
@ -61,7 +61,7 @@ public struct AppAccountView: View {
HStack {
if let account = viewModel.account {
ZStack(alignment: .topTrailing) {
AvatarView(url: account.avatar)
AvatarView(account: account)
if viewModel.appAccount.id == appAccounts.currentAccount.id {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)

View file

@ -15,7 +15,7 @@ public struct AppAccountsSelectorView: View {
@State private var isPresented: Bool = false
private let accountCreationEnabled: Bool
private let avatarSize: AvatarView.Size
private let avatarConfig: AvatarView.FrameConfig
private var showNotificationBadge: Bool {
accountsViewModel
@ -33,11 +33,11 @@ public struct AppAccountsSelectorView: View {
public init(routerPath: RouterPath,
accountCreationEnabled: Bool = true,
avatarSize: AvatarView.Size = .badge)
avatarConfig: AvatarView.FrameConfig = .badge)
{
self.routerPath = routerPath
self.accountCreationEnabled = accountCreationEnabled
self.avatarSize = avatarSize
self.avatarConfig = avatarConfig
}
public var body: some View {
@ -71,10 +71,10 @@ public struct AppAccountsSelectorView: View {
@ViewBuilder
private var labelView: some View {
Group {
if let avatar = currentAccount.account?.avatar, !currentAccount.isLoadingAccount {
AvatarView(url: avatar, size: avatarSize)
if let account = currentAccount.account, !currentAccount.isLoadingAccount {
AvatarView(account: account, config: avatarConfig)
} else {
AvatarView(url: nil, size: avatarSize)
AvatarView(account: nil, config: avatarConfig)
.redacted(reason: .placeholder)
.allowsHitTesting(false)
}

View file

@ -27,7 +27,7 @@ struct ConversationMessageView: View {
if isOwnMessage {
Spacer()
} else {
AvatarView(url: message.account.avatar, size: .status)
AvatarView(account: message.account, config: .status)
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: message.account))
}

View file

@ -24,7 +24,7 @@ struct ConversationsListRow: View {
} label: {
VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 8) {
AvatarView(url: conversation.accounts.first!.avatar)
AvatarView(account: conversation.accounts.first!)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
HStack {

View file

@ -2,104 +2,315 @@ import Nuke
import NukeUI
import Shimmer
import SwiftUI
import Models
@MainActor
public struct AvatarView: View {
@Environment(\.redactionReasons) private var reasons
@Environment(Theme.self) private var theme
public enum Size {
case account, status, embed, badge, list, boost
@State private var showPopup = false
@State private var autoDismiss = true
@State private var toggleTask: Task<Void, Never> = Task {}
public var size: CGSize {
switch self {
case .account:
return .init(width: 80, height: 80)
case .status:
if ProcessInfo.processInfo.isMacCatalystApp {
return .init(width: 48, height: 48)
}
return .init(width: 40, height: 40)
case .embed:
return .init(width: 34, height: 34)
case .badge:
return .init(width: 28, height: 28)
case .list:
return .init(width: 20, height: 20)
case .boost:
return .init(width: 12, height: 12)
}
}
var cornerRadius: CGFloat {
switch self {
case .badge, .boost, .list:
size.width / 2
default:
4
}
}
}
public let url: URL?
public let size: Size
public init(url: URL?, size: Size = .status) {
self.url = url
self.size = size
}
public let account: Account?
public let config: FrameConfig
public let hasPopup: Bool
public var body: some View {
Group {
if reasons == .placeholder {
RoundedRectangle(cornerRadius: size.cornerRadius)
.fill(.gray)
.frame(width: size.size.width, height: size.size.height)
} else {
LazyImage(request: url.map { makeImageRequest(for: $0) }) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fit)
} else {
AvatarPlaceholderView(size: size)
if let account = account {
if hasPopup {
AvatarImage(account: account, config: adaptiveConfig)
.onHover { hovering in
if hovering {
toggleTask.cancel()
toggleTask = Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
guard !Task.isCancelled else { return }
if !showPopup {
showPopup = true
}
}
} else {
if !showPopup {
toggleTask.cancel()
}
}
}
}
.frame(width: size.size.width, height: size.size.height)
.hoverEffect(.lift)
.popover(isPresented: $showPopup) {
AccountPopupView(
account: account,
theme: theme,
showPopup: $showPopup,
autoDismiss: $autoDismiss,
toggleTask: $toggleTask
)
}
} else {
AvatarImage(account: account, config: adaptiveConfig)
}
} else {
AvatarPlaceHolder(config: adaptiveConfig)
}
.clipShape(clipShape)
.overlay(
clipShape.stroke(Color.primary.opacity(0.25), lineWidth: 1)
)
}
private func makeImageRequest(for url: URL) -> ImageRequest {
ImageRequest(url: url, processors: [.resize(size: size.size)])
private var adaptiveConfig: FrameConfig {
var cornerRadius: CGFloat
if config == .badge || theme.avatarShape == .circle {
cornerRadius = config.width / 2
} else {
cornerRadius = config.cornerRadius
}
return FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius)
}
private var clipShape: some Shape {
switch theme.avatarShape {
case .circle:
AnyShape(Circle())
case .rounded:
AnyShape(RoundedRectangle(cornerRadius: size.cornerRadius))
public init(account: Account?, config: FrameConfig = FrameConfig.status, hasPopup: Bool = false) {
self.account = account
self.config = config
self.hasPopup = hasPopup
}
public struct FrameConfig: Equatable {
public let size: CGSize
public var width: CGFloat { size.width }
public var height: CGFloat { size.height }
let cornerRadius: CGFloat
init(width: CGFloat, height: CGFloat, cornerRadius: CGFloat = 4) {
self.size = CGSize(width: width, height: height)
self.cornerRadius = cornerRadius
}
public static let account = FrameConfig(width: 80, height: 80)
public static let status = {
if ProcessInfo.processInfo.isMacCatalystApp {
return FrameConfig(width: 48, height: 48)
}
return FrameConfig(width: 40, height: 40)
}()
public static let embed = FrameConfig(width: 34, height: 34)
public static let badge = FrameConfig(width: 28, height: 28, cornerRadius: 14)
public static let list = FrameConfig(width: 20, height: 20, cornerRadius: 10)
public static let boost = FrameConfig(width: 12, height: 12, cornerRadius: 6)
}
}
private struct AvatarPlaceholderView: View {
let size: AvatarView.Size
struct AvatarView_Previews: PreviewProvider {
static var previews: some View {
PreviewWrapper()
.padding()
.previewLayout(.sizeThatFits)
}
}
struct PreviewWrapper: View {
@State private var isCircleAvatar = false
var body: some View {
if size == .badge {
Circle()
.fill(.gray)
.frame(width: size.size.width, height: size.size.height)
VStack(alignment: .leading) {
AvatarView(account: Self.account, config: .status)
.environment(Theme.shared)
Toggle("Avatar Shape", isOn: $isCircleAvatar)
}
.onChange(of: isCircleAvatar) {
Theme.shared.avatarShape = self.isCircleAvatar ? .circle : .rounded
}
.onAppear {
Theme.shared.avatarShape = self.isCircleAvatar ? .circle : .rounded
}
}
private static let account = Account(
id: UUID().uuidString,
username: "@clattner_llvm",
displayName: "Chris Lattner",
avatar: URL(string: "https://pbs.twimg.com/profile_images/1484209565788897285/1n6Viahb_400x400.jpg")!,
header: URL(string: "https://pbs.twimg.com/profile_banners/2543588034/1656822255/1500x500")!,
acct: "clattner_llvm@example.com",
note: .init(stringValue: "Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. Were hiring! 🚀🧠"),
createdAt: ServerDate(),
followersCount: 77100,
followingCount: 167,
statusesCount: 123,
lastStatusAt: nil,
fields: [],
locked: false,
emojis: [],
url: URL(string: "https://nondot.org/sabre/")!,
source: nil,
bot: false,
discoverable: true)
}
struct AvatarImage: View {
@Environment(\.redactionReasons) private var reasons
public let account: Account
public let config: AvatarView.FrameConfig
var body: some View {
if reasons == .placeholder {
AvatarPlaceHolder(config: config)
} else {
RoundedRectangle(cornerRadius: size.cornerRadius)
.fill(.gray)
.frame(width: size.size.width, height: size.size.height)
LazyImage(request: ImageRequest(url: account.avatar, processors: [.resize(size: config.size)])
) { state in
if let image = state.image {
image.resizable().scaledToFill()
.frame(width: config.width, height: config.height)
.clipShape(RoundedRectangle(cornerRadius: config.cornerRadius))
.overlay(
RoundedRectangle(cornerRadius: config.cornerRadius)
.stroke(.primary.opacity(0.25), lineWidth: 1)
)
}
}
}
}
}
struct AvatarPlaceHolder: View {
let config: AvatarView.FrameConfig
var body: some View {
RoundedRectangle(cornerRadius: config.cornerRadius)
.fill(.gray)
.frame(width: config.width, height: config.height)
}
}
struct AccountPopupView: View {
let account: Account
let theme: Theme // using `@Environment(Theme.self) will crash the SwiftUI preview
private let config: AvatarView.FrameConfig = .account
@Binding var showPopup: Bool
@Binding var autoDismiss: Bool
@Binding var toggleTask: Task<Void, Never>
var body: some View {
VStack(alignment: .leading) {
LazyImage(request: ImageRequest(url: account.header)
) { state in
if let image = state.image {
image.resizable().scaledToFill()
}
}
.frame(width: 500, height: 150)
.clipped()
.background(theme.secondaryBackgroundColor)
VStack(alignment: .leading) {
HStack(alignment: .bottomAvatar) {
AvatarImage(account: account, config: adaptiveConfig)
Spacer()
makeCustomInfoLabel(title: "account.following", count: account.followingCount ?? 0)
makeCustomInfoLabel(title: "account.posts", count: account.statusesCount ?? 0)
makeCustomInfoLabel(title: "account.followers", count: account.followersCount ?? 0)
}
.frame(height: adaptiveConfig.height / 2, alignment: .bottom)
EmojiTextApp(.init(stringValue: account.safeDisplayName ), emojis: account.emojis)
.font(.headline)
.foregroundColor(theme.labelColor)
.emojiSize(Font.scaledHeadlineFont.emojiSize)
.emojiBaselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset)
.accessibilityAddTraits(.isHeader)
.help(account.safeDisplayName)
Text("@\(account.acct)")
.font(.callout)
.foregroundColor(.gray)
.textSelection(.enabled)
.accessibilityRespondsToUserInteraction(false)
.help("@\(account.acct)")
HStack(spacing: 4) {
Image(systemName: "calendar")
.accessibilityHidden(true)
Text("account.joined")
Text(account.createdAt.asDate, style: .date)
}
.foregroundColor(.gray)
.font(.footnote)
.accessibilityElement(children: .combine)
EmojiTextApp(account.note, emojis: account.emojis, lineLimit: 5)
.font(.body)
.emojiSize(Font.scaledFootnoteFont.emojiSize)
.emojiBaselineOffset(Font.scaledFootnoteFont.emojiBaselineOffset)
.padding(.top, 3)
}
.padding([.leading, .trailing, .bottom])
}
.frame(width: 500)
.onAppear {
toggleTask.cancel()
toggleTask = Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
guard !Task.isCancelled else { return }
if autoDismiss {
showPopup = false
}
}
}
.onHover { hovering in
toggleTask.cancel()
toggleTask = Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
guard !Task.isCancelled else { return }
if hovering {
autoDismiss = false
} else {
showPopup = false
autoDismiss = true
}
}
}
}
@MainActor
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View {
VStack {
Text(count, format: .number.notation(.compactName))
.font(.scaledHeadline)
.foregroundColor(theme.tintColor)
.overlay(alignment: .trailing) {
if needsBadge {
Circle()
.fill(Color.red)
.frame(width: 9, height: 9)
.offset(x: 12)
}
}
Text(title)
.font(.scaledFootnote)
.foregroundColor(.gray)
.alignmentGuide(.bottomAvatar, computeValue: { dimension in
dimension[.firstTextBaseline]
})
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(title)
.accessibilityValue("\(count)")
}
private var adaptiveConfig: AvatarView.FrameConfig {
var cornerRadius: CGFloat
if config == .badge || theme.avatarShape == .circle {
cornerRadius = config.width / 2
} else {
cornerRadius = config.cornerRadius
}
return AvatarView.FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius)
}
}
private enum BottomAvatarAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context.height
}
}
extension VerticalAlignment {
static let bottomAvatar = VerticalAlignment(BottomAvatarAlignment.self)
}

View file

@ -30,7 +30,7 @@ public struct ListEditView: View {
} else {
ForEach(viewModel.accounts) { account in
HStack {
AvatarView(url: account.avatar, size: .status)
AvatarView(account: account, config: .status)
VStack(alignment: .leading) {
EmojiTextApp(.init(stringValue: account.safeDisplayName),
emojis: account.emojis)

View file

@ -23,8 +23,8 @@ struct NotificationRowView: View {
.accessibilityHidden(true)
} else {
makeNotificationIconView(type: notification.type)
.frame(width: AvatarView.Size.status.size.width,
height: AvatarView.Size.status.size.height)
.frame(width: AvatarView.FrameConfig.status.width,
height: AvatarView.FrameConfig.status.height)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
@ -52,7 +52,7 @@ struct NotificationRowView: View {
private func makeAvatarView(type: Models.Notification.NotificationType) -> some View {
ZStack(alignment: .topLeading) {
AvatarView(url: notification.accounts[0].avatar)
AvatarView(account: notification.accounts[0], hasPopup: true)
makeNotificationIconView(type: type)
.offset(x: -8, y: -8)
}
@ -83,7 +83,7 @@ struct NotificationRowView: View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(notification.accounts) { account in
AvatarView(url: account.avatar)
AvatarView(account: account, hasPopup: true)
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: account))
@ -91,7 +91,7 @@ struct NotificationRowView: View {
}
}
.padding(.leading, 1)
.frame(height: AvatarView.Size.status.size.height + 2)
.frame(height: AvatarView.FrameConfig.status.size.height + 2)
}.offset(y: -1)
}
HStack(spacing: 0) {

View file

@ -31,7 +31,7 @@ struct StatusEditorAutoCompleteView: View {
viewModel.selectMentionSuggestion(account: account)
} label: {
HStack {
AvatarView(url: account.avatar, size: .badge)
AvatarView(account: account, config: AvatarView.FrameConfig.badge)
VStack(alignment: .leading) {
EmojiTextApp(.init(stringValue: account.safeDisplayName),
emojis: account.emojis)

View file

@ -246,9 +246,9 @@ public struct StatusEditorView: View {
if viewModel.mode.isInShareExtension {
AppAccountsSelectorView(routerPath: RouterPath(),
accountCreationEnabled: false,
avatarSize: .status)
avatarConfig: .status)
} else {
AvatarView(url: account.avatar, size: .status)
AvatarView(account: account, config: AvatarView.FrameConfig.status)
.environment(theme)
.accessibilityHidden(true)
}

View file

@ -46,7 +46,7 @@ public struct StatusEmbeddedView: View {
private func makeAccountView(account: Account) -> some View {
HStack(alignment: .center) {
AvatarView(url: account.avatar, size: .embed)
AvatarView(account: account, config: .embed, hasPopup: true)
VStack(alignment: .leading, spacing: 0) {
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
.font(.scaledFootnote)

View file

@ -57,7 +57,7 @@ public struct StatusRowView: View {
StatusRowReblogView(viewModel: viewModel)
StatusRowReplyView(viewModel: viewModel)
}
.padding(.leading, AvatarView.Size.status.size.width + .statusColumnsSpacing)
.padding(.leading, AvatarView.FrameConfig.status.width + .statusColumnsSpacing)
}
HStack(alignment: .top, spacing: .statusColumnsSpacing) {
if !isCompact,
@ -66,7 +66,7 @@ public struct StatusRowView: View {
Button {
viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account)
} label: {
AvatarView(url: viewModel.finalStatus.account.avatar, size: .status)
AvatarView(account: viewModel.finalStatus.account, config: .status, hasPopup: true)
}
}
VStack(alignment: .leading) {

View file

@ -103,7 +103,7 @@ struct StatusRowDetailView: View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 0) {
ForEach(accounts) { account in
AvatarView(url: account.avatar, size: .list)
AvatarView(account: account, config: .list, hasPopup: true)
.padding(.leading, -4)
}
.transition(.opacity)

View file

@ -42,7 +42,7 @@ struct StatusRowHeaderView: View {
private var accountView: some View {
HStack(alignment: .center) {
if theme.avatarPosition == .top {
AvatarView(url: viewModel.finalStatus.account.avatar, size: .status)
AvatarView(account: viewModel.finalStatus.account, config: .status, hasPopup: true)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 2) {

View file

@ -31,7 +31,7 @@ public struct StatusRowMediaPreviewView: View {
}
var appLayoutWidth: CGFloat {
let avatarColumnWidth = theme.avatarPosition == .leading ? AvatarView.Size.status.size.width + .statusColumnsSpacing : 0
let avatarColumnWidth = theme.avatarPosition == .leading ? AvatarView.FrameConfig.status.width + .statusColumnsSpacing : 0
var sidebarWidth: CGFloat = 0
var secondaryColumnWidth: CGFloat = 0
let layoutPading: CGFloat = .layoutPadding * 2

View file

@ -8,7 +8,7 @@ struct StatusRowReblogView: View {
if viewModel.status.reblog != nil {
HStack(spacing: 2) {
Image("Rocket.Fill")
AvatarView(url: viewModel.status.account.avatar, size: .boost)
AvatarView(account: viewModel.status.account, config: .boost, hasPopup: true)
EmojiTextApp(.init(stringValue: viewModel.status.account.safeDisplayName), emojis: viewModel.status.account.emojis)
Text("status.row.was-boosted")
}