diff --git a/Sources/BraveWallet/AdjustableHeightAttributedTextView.swift b/Sources/BraveWallet/AdjustableHeightAttributedTextView.swift index 344f82ab20c6..97543bc9b2fb 100644 --- a/Sources/BraveWallet/AdjustableHeightAttributedTextView.swift +++ b/Sources/BraveWallet/AdjustableHeightAttributedTextView.swift @@ -47,6 +47,8 @@ struct AttributedTextView: UIViewRepresentable { $0.delegate = context.coordinator $0.isScrollEnabled = false $0.textAlignment = .center + $0.textContainer.lineFragmentPadding = 0 + $0.textContainerInset = .zero } return textView } diff --git a/Sources/BraveWallet/Crypto/AssetIconView.swift b/Sources/BraveWallet/Crypto/AssetIconView.swift index 48db9ebb91bf..529f4bdbb0b0 100644 --- a/Sources/BraveWallet/Crypto/AssetIconView.swift +++ b/Sources/BraveWallet/Crypto/AssetIconView.swift @@ -137,6 +137,8 @@ struct NFTIconView: View { var url: URL? /// If we should show the network logo on non-native assets var shouldShowNetworkIcon: Bool = false + /// View is loading NFT metadata + var isLoadingMetadata: Bool = false @ScaledMetric var length: CGFloat = 40 var maxLength: CGFloat? @@ -160,13 +162,8 @@ struct NFTIconView: View { } var body: some View { - NFTImageView(urlString: url?.absoluteString ?? "") { - AssetIconView( - token: token, - network: network, - shouldShowNetworkIcon: shouldShowNetworkIcon, - length: length - ) + NFTImageView(urlString: url?.absoluteString ?? "", isLoading: isLoadingMetadata) { + LoadingNFTView(shimmer: false) } .cornerRadius(5) .frame( diff --git a/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift b/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift index 7a577ee2395e..23cef9662378 100644 --- a/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift +++ b/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift @@ -23,18 +23,11 @@ struct NFTDetailView: View { } @ViewBuilder private var nftImage: some View { - if let nftMetadata = nftDetailStore.nftMetadata { - if let urlString = nftMetadata.imageURLString { - NFTImageView(urlString: urlString) { - noImageView - } - .cornerRadius(10) - } else { - noImageView - } - } else { + NFTImageView(urlString: nftDetailStore.nftMetadata?.imageURLString ?? "", isLoading: nftDetailStore.isLoading) { noImageView } + .cornerRadius(10) + .frame(maxWidth: .infinity, minHeight: 300) } private var isSVGImage: Bool { @@ -46,32 +39,27 @@ struct NFTDetailView: View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 24) { VStack(spacing: 8) { - if nftDetailStore.isLoading { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 300) - } else { - nftImage - .overlay(alignment: .topLeading) { - if nftDetailStore.nft.isSpam { - HStack(spacing: 4) { - Text(Strings.Wallet.nftSpam) - .padding(.vertical, 4) - .padding(.leading, 6) - .foregroundColor(Color(.braveErrorLabel)) - Image(braveSystemName: "leo.warning.triangle-outline") - .padding(.vertical, 4) - .padding(.trailing, 6) - .foregroundColor(Color(.braveErrorBorder)) - } - .font(.system(size: 13).weight(.semibold)) - .background( - Color(uiColor: WalletV2Design.spamNFTLabelBackground) - .cornerRadius(4) - ) - .padding(12) + nftImage + .overlay(alignment: .topLeading) { + if nftDetailStore.nft.isSpam { + HStack(spacing: 4) { + Text(Strings.Wallet.nftSpam) + .padding(.vertical, 4) + .padding(.leading, 6) + .foregroundColor(Color(.braveErrorLabel)) + Image(braveSystemName: "leo.warning.triangle-outline") + .padding(.vertical, 4) + .padding(.trailing, 6) + .foregroundColor(Color(.braveErrorBorder)) } + .font(.system(size: 13).weight(.semibold)) + .background( + Color(uiColor: WalletV2Design.spamNFTLabelBackground) + .cornerRadius(4) + ) + .padding(12) } - } + } VStack(alignment: .leading, spacing: 8) { Text(nftDetailStore.nft.nftTokenTitle) .font(.title3.weight(.semibold)) diff --git a/Sources/BraveWallet/Crypto/NFT/NFTView.swift b/Sources/BraveWallet/Crypto/NFT/NFTView.swift index d5331f0d7021..7d8f46c53265 100644 --- a/Sources/BraveWallet/Crypto/NFT/NFTView.swift +++ b/Sources/BraveWallet/Crypto/NFT/NFTView.swift @@ -75,8 +75,12 @@ struct NFTView: View { if let image = nftViewModel.network.nativeTokenLogoImage, nftStore.filters.isShowingNFTNetworkLogo { Image(uiImage: image) .resizable() - .frame(width: 20, height: 20) - .padding(4) + .overlay { + Circle() + .stroke(lineWidth: 2) + .foregroundColor(Color(braveSystemName: .containerBackground)) + } + .frame(width: 24, height: 24) } } @@ -84,27 +88,15 @@ struct NFTView: View { Group { if let urlString = nftViewModel.nftMetadata?.imageURLString { NFTImageView(urlString: urlString) { - noImageView(nftViewModel) + LoadingNFTView(shimmer: false) } } else { - noImageView(nftViewModel) + LoadingNFTView(shimmer: false) } } - .overlay(nftLogo(nftViewModel), alignment: .bottomTrailing) .cornerRadius(4) } - @ViewBuilder private func noImageView(_ nftViewModel: NFTAssetViewModel) -> some View { - Blockie(address: nftViewModel.token.contractAddress, shape: .rectangle) - .overlay( - Text(nftViewModel.token.symbol.first?.uppercased() ?? "") - .font(.system(size: 80, weight: .bold, design: .rounded)) - .foregroundColor(.white) - .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) - ) - .aspectRatio(1.0, contentMode: .fit) - } - private var filtersButton: some View { AssetButton(braveSystemName: "leo.filter.settings", action: { isPresentingFiltersDisplaySettings = true @@ -122,18 +114,21 @@ struct NFTView: View { } } .pickerStyle(.inline) + .disabled(nftStore.isShowingNFTLoadingState) } label: { HStack(spacing: 12) { Text(nftStore.displayType.dropdownTitle) .font(.subheadline.weight(.semibold)) - Text("\(nftStore.totalDisplayedNFTCount)") - .padding(.horizontal, 8) - .padding(.vertical, 4) - .font(.caption2.weight(.semibold)) - .background( - Color(braveSystemName: .primary20) - .cornerRadius(4) - ) + if !nftStore.isShowingNFTLoadingState { + Text("\(nftStore.totalDisplayedNFTCount)") + .padding(.horizontal, 8) + .padding(.vertical, 4) + .font(.caption2.weight(.semibold)) + .background( + Color(braveSystemName: .primary20) + .cornerRadius(4) + ) + } Image(braveSystemName: "leo.carat.down") .font(.subheadline.weight(.semibold)) } @@ -146,7 +141,9 @@ struct NFTView: View { Spacer() addCustomAssetButton .padding(.trailing, 10) + .disabled(nftStore.isShowingNFTLoadingState) filtersButton + .disabled(nftStore.isShowingNFTLoadingState) } .padding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) @@ -161,7 +158,7 @@ struct NFTView: View { private var nftDiscoveryDescriptionText: NSAttributedString? { let attributedString = NSMutableAttributedString( string: Strings.Wallet.nftDiscoveryCalloutDescription, - attributes: [.foregroundColor: UIColor.braveLabel, .font: UIFont.preferredFont(for: .subheadline, weight: .regular)] + attributes: [.foregroundColor: UIColor.secondaryBraveLabel, .font: UIFont.preferredFont(for: .subheadline, weight: .regular)] ) attributedString.addAttributes([.underlineStyle: NSUnderlineStyle.single.rawValue], range: (attributedString.string as NSString).range(of: "SimpleHash")) // `SimpleHash` won't get translated @@ -183,6 +180,10 @@ struct NFTView: View { }) { VStack(alignment: .leading, spacing: 4) { nftImage(nft) + .overlay(alignment: .bottomTrailing) { + nftLogo(nft) + .offset(y: 12) + } .padding(.bottom, 8) Text(nft.token.nftTokenTitle) .font(.callout.weight(.medium)) @@ -273,7 +274,9 @@ struct NFTView: View { var body: some View { LazyVStack(spacing: 16) { nftHeaderView - if nftStore.isShowingNFTEmptyState { + if nftStore.isShowingNFTLoadingState { + SkeletonLoadingNFTView() + } else if nftStore.isShowingNFTEmptyState { emptyView } else { ForEach(nftStore.displayNFTGroups) { group in @@ -334,10 +337,9 @@ struct NFTView: View { ), showCloseButton: false, content: { - VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 10) { Text(Strings.Wallet.nftDiscoveryCalloutTitle) - .font(.headline.weight(.bold)) - .multilineTextAlignment(.center) + .font(.body.weight(.medium)) if let attrString = nftDiscoveryDescriptionText { AdjustableHeightAttributedTextView( attributedString: attrString, @@ -349,6 +351,7 @@ struct NFTView: View { ) } } + .padding(.bottom, 24) } ) ) @@ -382,6 +385,8 @@ struct NFTView: View { .font(.footnote) .foregroundStyle(Color(.secondaryBraveLabel)) } + .multilineTextAlignment(.center) + .padding(.bottom, 24) }) ) .sheet(isPresented: $isShowingAddCustomNFT) { @@ -427,6 +432,35 @@ struct NFTView: View { } } +struct SkeletonLoadingNFTView: View { + + private let nftGrids = [GridItem(.adaptive(minimum: 160), spacing: 16, alignment: .top)] + + var body: some View { + LazyVGrid(columns: nftGrids) { + ForEach(0..<6) { _ in + VStack(alignment: .leading, spacing: 6) { + LoadingNFTView() + .frame(height: 176) + Group { + Color(braveSystemName: .containerHighlight) + .frame(width: 148, height: 12) + Color(braveSystemName: .containerHighlight) + .frame(width: 96, height: 12) + } + .clipShape(Capsule()) + .redacted(reason: .placeholder) + .shimmer(true) + } + .padding(.horizontal, 8) + .padding(.top, 8) + .padding(.bottom, 24) + .accessibilityHidden(true) + } + } + } +} + #if DEBUG struct NFTView_Previews: PreviewProvider { static var previews: some View { diff --git a/Sources/BraveWallet/Crypto/NFTImageView.swift b/Sources/BraveWallet/Crypto/NFTImageView.swift index 713f4e3f6ee9..8410c6c34d4d 100644 --- a/Sources/BraveWallet/Crypto/NFTImageView.swift +++ b/Sources/BraveWallet/Crypto/NFTImageView.swift @@ -10,35 +10,24 @@ import SDWebImageSwiftUI struct NFTImageView: View { private let urlString: String private var placeholder: () -> Placeholder + var isLoading: Bool init( urlString: String, + isLoading: Bool = false, @ViewBuilder placeholder: @escaping () -> Placeholder ) { self.urlString = urlString + self.isLoading = isLoading self.placeholder = placeholder } var body: some View { - if let url = URL(string: urlString) { - if url.absoluteString.hasPrefix("data:image/") { - WebImageReader(url: url) { image in - if let image = image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - placeholder() - } - } - } else if url.isSecureWebPage() { - if url.absoluteString.hasSuffix(".gif") { - WebImage(url: url) - .resizable() - .placeholder { placeholder() } - .indicator(.activity) - .aspectRatio(contentMode: .fit) - } else { + if isLoading { + LoadingNFTView() + } else { + if let url = URL(string: urlString) { + if url.absoluteString.hasPrefix("data:image/") { WebImageReader(url: url) { image in if let image = image { Image(uiImage: image) @@ -48,12 +37,56 @@ struct NFTImageView: View { placeholder() } } + } else if url.isSecureWebPage() { + if url.absoluteString.hasSuffix(".gif") { + WebImage(url: url) + .resizable() + .placeholder { placeholder() } + .indicator(.activity) + .aspectRatio(contentMode: .fit) + } else { + WebImageReader(url: url) { image in + if let image = image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + placeholder() + } + } + } + } else { + placeholder() } } else { placeholder() } - } else { - placeholder() } } } + +struct LoadingNFTView: View { + var shimmer: Bool = true + @State var viewSize: CGSize = .zero + var body: some View { + Color(braveSystemName: .containerHighlight) + .cornerRadius(4) + .redacted(reason: .placeholder) + .shimmer(shimmer) + .overlay { + Image(braveSystemName: "leo.nft") + .foregroundColor(Color(braveSystemName: .containerBackground)) + .font(.system(size: floor(viewSize.width / 3))) + } + .background( + GeometryReader { geometryProxy in + Color.clear + .preference(key: SizePreferenceKey.self, value: geometryProxy.size) + } + ) + .frame(minHeight: viewSize.width) + .onPreferenceChange(SizePreferenceKey.self) { newSize in + viewSize = newSize + } + } +} diff --git a/Sources/BraveWallet/Crypto/Search/AssetSearchView.swift b/Sources/BraveWallet/Crypto/Search/AssetSearchView.swift index 6e4c0c141956..542fec75b5d6 100644 --- a/Sources/BraveWallet/Crypto/Search/AssetSearchView.swift +++ b/Sources/BraveWallet/Crypto/Search/AssetSearchView.swift @@ -22,6 +22,7 @@ struct AssetSearchView: View { @State private var networkFilters: [Selectable] = [] @State private var isPresentingNetworkFilter = false @State private var selectedToken: BraveWallet.BlockchainToken? + @State private var isLoadingMetadata: Bool = false public init( keyringStore: KeyringStore, @@ -108,7 +109,8 @@ struct AssetSearchView: View { token: assetViewModel.token, network: assetViewModel.network, url: allNFTMetadata[assetViewModel.token.id]?.imageURL, - shouldShowNetworkIcon: true + shouldShowNetworkIcon: true, + isLoadingMetadata: isLoadingMetadata ) } else { AssetIconView( @@ -187,7 +189,9 @@ struct AssetSearchView: View { .onAppear { Task { @MainActor in self.allAssets = await userAssetsStore.allAssets() + self.isLoadingMetadata = true self.allNFTMetadata = await userAssetsStore.allNFTMetadata() + self.isLoadingMetadata = false self.networkFilters = networkStore.allChains.map { .init(isSelected: true, model: $0) } diff --git a/Sources/BraveWallet/Crypto/Stores/NFTStore.swift b/Sources/BraveWallet/Crypto/Stores/NFTStore.swift index 662d6ab3a1c4..485a43720bbb 100644 --- a/Sources/BraveWallet/Crypto/Stores/NFTStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/NFTStore.swift @@ -133,6 +133,8 @@ public class NFTStore: ObservableObject, WalletObserverStore { @Published var displayType: NFTDisplayType = .visible /// View model for all NFT include visible, hidden and spams @Published private(set) var userNFTGroups: [NFTGroupViewModel] = [] + /// showing shimmering loading state when the view finishes loading NFT information + @Published var isShowingNFTLoadingState: Bool = false private let keyringService: BraveWalletKeyringService private let rpcService: BraveWalletJsonRpcService @@ -247,6 +249,7 @@ public class NFTStore: ObservableObject, WalletObserverStore { private var nftBalancesCache: [String: [String: Int]] = [:] func update() { + self.isShowingNFTLoadingState = true self.updateTask?.cancel() self.updateTask = Task { @MainActor in self.allAccounts = await keyringService.allAccounts().accounts @@ -357,6 +360,8 @@ public class NFTStore: ObservableObject, WalletObserverStore { selectedAccounts: selectedAccounts, selectedNetworks: selectedNetworks ) + + isShowingNFTLoadingState = false } } @@ -572,6 +577,7 @@ public class NFTStore: ObservableObject, WalletObserverStore { isSpam: Bool, isDeletedByUser: Bool ) { + isShowingNFTLoadingState = true assetManager.updateUserAsset( for: token, visible: visible, @@ -600,6 +606,8 @@ public class NFTStore: ObservableObject, WalletObserverStore { selectedAccounts: selectedAccounts, selectedNetworks: selectedNetworks ) + + isShowingNFTLoadingState = false } } } diff --git a/Sources/BraveWallet/WalletPromptView.swift b/Sources/BraveWallet/WalletPromptView.swift index 1afe82672d17..2d207209ab99 100644 --- a/Sources/BraveWallet/WalletPromptView.swift +++ b/Sources/BraveWallet/WalletPromptView.swift @@ -40,28 +40,46 @@ struct WalletPromptContentView: View where Content: View, Foote content() if let secondaryButton = self.secondaryButton { if buttonsAxis == .vertical { - VStack(spacing: 12) { - Button(primaryButton.title, action: { primaryButton.action(nil) }) - .buttonStyle(BraveFilledButtonStyle(size: .large)) - Button(secondaryButton.title, action: { secondaryButton.action(nil) }) - .foregroundColor(Color(.braveLabel)) + VStack(spacing: 24) { + Button { primaryButton.action(nil) } label: { + Text(primaryButton.title) + .font(.footnote.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(BraveFilledButtonStyle(size: .large)) + Button { secondaryButton.action(nil) } label: { + Text(secondaryButton.title) + .font(.footnote.weight(.semibold)) + .foregroundColor(Color(.bravePrimary)) + .frame(maxWidth: .infinity) + } } } else { HStack { - Button(secondaryButton.title, action: { secondaryButton.action(nil) }) - .buttonStyle(BraveOutlineButtonStyle(size: .large)) - Button(primaryButton.title, action: { primaryButton.action(nil) }) - .buttonStyle(BraveFilledButtonStyle(size: .large)) + Button { secondaryButton.action(nil) } label: { + Text(secondaryButton.title) + .font(.footnote.weight(.semibold)) + } + .buttonStyle(BraveOutlineButtonStyle(size: .large)) + Button { primaryButton.action(nil) } label: { + Text(primaryButton.title) + .font(.footnote.weight(.semibold)) + } + .buttonStyle(BraveFilledButtonStyle(size: .large)) } } } else { - Button(primaryButton.title, action: { primaryButton.action(nil) }) - .buttonStyle(BraveFilledButtonStyle(size: .large)) + Button { primaryButton.action(nil) } label: { + Text(primaryButton.title) + .font(.footnote.weight(.semibold)) + } + .buttonStyle(BraveFilledButtonStyle(size: .large)) } footer() } .frame(maxWidth: .infinity) - .padding(20) + .padding(.horizontal, 24) + .padding(.vertical, 32) .overlay( showCloseButton ? Button(action: { dismissAction?() }) {