Skip to content
This repository has been archived by the owner on May 10, 2024. It is now read-only.

Fix #8548: Refactor PortfolioSegmentedControl to be reusable #8549

Merged
merged 1 commit into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 129 additions & 86 deletions Sources/BraveWallet/Crypto/Portfolio/PortfolioSegmentedControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import Shared

struct PortfolioSegmentedControl: View {

enum SelectedContent: Int, Equatable, CaseIterable, Identifiable {
enum Item: Int, Equatable, CaseIterable, Identifiable, WalletSegmentedControlItem {
case assets
case nfts

var displayText: String {
var title: String {
switch self {
case .assets: return Strings.Wallet.assetsTitle
case .nfts: return Strings.Wallet.nftsTitle
Expand All @@ -27,83 +27,105 @@ struct PortfolioSegmentedControl: View {
var id: Int { rawValue }
}

@Binding var selected: SelectedContent
@Binding var selected: Item

var body: some View {
WalletSegmentedControl(
items: Item.allCases,
selected: $selected
)
}
}

#if DEBUG
struct PortfolioSegmentedControl_Previews: PreviewProvider {
static var previews: some View {
PortfolioSegmentedControl(
selected: .constant(.nfts)
)
}
}
#endif

protocol WalletSegmentedControlItem: Equatable, Hashable, Identifiable {
var title: String { get }
}

struct WalletSegmentedControl<Item: WalletSegmentedControlItem>: View {

let items: [Item]
@Binding var selected: Item
let dynamicTypeRange = (...DynamicTypeSize.xxxLarge)

var minHeight: CGFloat = 40
@ScaledMetric var height: CGFloat = 40
var maxHeight: CGFloat = 60

@State private var viewSize: CGSize = .zero
@State private var location: CGPoint = .zero
@GestureState private var isDragGestureActive: Bool = false

var body: some View {
GeometryReader { geometryProxy in
Capsule()
.fill(Color(braveSystemName: .containerHighlight))
.osAvailabilityModifiers {
if #unavailable(iOS 16) {
$0.overlay {
// TapGesture does not give a location,
// SpatialTapGesture is iOS 16+.
HStack {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
select(.assets)
}
Capsule()
.fill(Color(braveSystemName: .containerHighlight))
.osAvailabilityModifiers {
if #unavailable(iOS 16) {
$0.overlay {
// TapGesture does not give a location,
// SpatialTapGesture is iOS 16+.
HStack {
ForEach(items) { item in
Color.clear
.contentShape(Rectangle())
.onTapGesture {
select(.nfts)
select(item)
}
}
}
} else {
$0
}
}
.overlay {
Capsule()
.fill(Color(braveSystemName: .containerBackground))
.padding(4)
.frame(width: geometryProxy.size.width / 2)
.position(location)
}
.overlay {
HStack {
Spacer()
Text(SelectedContent.assets.displayText)
.font(.subheadline.weight(.semibold))
.foregroundColor(Color(braveSystemName: selected == .assets ? .textPrimary : .textSecondary))
.allowsHitTesting(false)
Spacer()
Spacer()
Text(SelectedContent.nfts.displayText)
.font(.subheadline.weight(.semibold))
.foregroundColor(Color(braveSystemName: selected == .nfts ? .textPrimary : .textSecondary))
.allowsHitTesting(false)
Spacer()
}
} else {
$0
}
.readSize { size in
if location == .zero {
let newX = selected == .assets ? geometryProxy.size.width / 4 : geometryProxy.size.width / 4 * 3
location = CGPoint(
x: newX,
y: geometryProxy.size.height / 2
)
}
.overlay { // selected capsule
Capsule()
.fill(Color(braveSystemName: .containerBackground))
.padding(4)
.frame(width: itemWidth)
.position(location)
}
.overlay { // text for each item
HStack {
ForEach(items) { item in
titleView(for: item)

if item != items.last {
Spacer()
}
}
viewSize = size
}
}
.frame(height: 40)
}
.readSize { size in
viewSize = size
}
.frame(height: min(max(height, minHeight), maxHeight))
.gesture(dragGesture)
.onChange(of: isDragGestureActive) { isDragGestureActive in
if !isDragGestureActive { // cancellation of gesture, ex while scrolling
var newX = location.x
if newX < viewSize.width / 2 {
select(.assets)
} else {
select(.nfts)
if let itemForLocation = item(for: location) {
select(itemForLocation)
}
}
}
.onChange(of: viewSize) { viewSize in
if location == .zero {
// set initial location
select(selected, animated: false)
} else if !isDragGestureActive {
// possible when accessibility size changes
select(selected, animated: false)
}
}
.osAvailabilityModifiers {
if #available(iOS 16, *) {
$0.simultaneousGesture(tapGesture)
Expand All @@ -113,8 +135,8 @@ struct PortfolioSegmentedControl: View {
}
.accessibilityRepresentation {
Picker(selection: $selected) {
ForEach(SelectedContent.allCases) { content in
Text(content.displayText).tag(content)
ForEach(items) { item in
Text(item.title).tag(item.id)
}
} label: {
EmptyView()
Expand All @@ -123,28 +145,40 @@ struct PortfolioSegmentedControl: View {
}
}

private func select(_ item: Item, animated: Bool = true) {
selected = item
withAnimation(animated ? .spring() : nil) {
location = location(for: item)
}
}

private func titleView(for item: Item) -> some View {
Text(item.title)
.font(.subheadline.weight(.semibold))
.foregroundColor(Color(braveSystemName: selected == item ? .textPrimary : .textSecondary))
.dynamicTypeSize(dynamicTypeRange)
.allowsHitTesting(false)
.frame(width: itemWidth)
}

private var dragGesture: some Gesture {
DragGesture()
.updating($isDragGestureActive) { value, state, transaction in
state = true
}
.onChanged { value in
var newX = value.location.x
if newX < viewSize.width / 4 {
newX = viewSize.width / 4
} else if newX > (viewSize.width / 4 * 3) {
newX = (viewSize.width / 4 * 3)
}
// `location` is the middle of capsule
let minX = itemWidth / 2
let maxX = viewSize.width - minX
let newX = min(max(value.location.x, minX), maxX)
location = CGPoint(
x: newX,
y: location.y
)
}
.onEnded { value in
if value.predictedEndLocation.x <= viewSize.width / 2 {
select(.assets)
} else {
select(.nfts)
if let itemForLocation = item(for: value.predictedEndLocation) {
select(itemForLocation)
}
}
}
Expand All @@ -153,26 +187,35 @@ struct PortfolioSegmentedControl: View {
private var tapGesture: some Gesture {
SpatialTapGesture()
.onEnded { value in
if value.location.x < viewSize.width / 2 {
select(.assets)
} else {
select(.nfts)
if let itemForLocation = item(for: value.location) {
select(itemForLocation)
}
}
}

private func select(_ selectedContent: SelectedContent) {
selected = selectedContent
withAnimation(.spring()) {
var newX = viewSize.width / 4
if selectedContent == .nfts {
newX *= 3
}
location = CGPoint(
x: newX,
y: viewSize.height / 2
)
}
private var itemWidth: CGFloat {
viewSize.width / CGFloat(items.count)
}

private func location(for item: Item) -> CGPoint {
CGPoint(
x: xPosition(for: item),
y: viewSize.height / 2
)
}

private func xPosition(for item: Item) -> CGFloat {
let itemWidth = viewSize.width / CGFloat(items.count)
let firstItemPosition = itemWidth / 2
let selectedIndex = items.firstIndex(of: item) ?? 0
let newX = firstItemPosition + (CGFloat(selectedIndex) * itemWidth)
return newX
}

private func item(for location: CGPoint) -> Item? {
let percent = location.x / viewSize.width
let itemIndex = Int(percent * CGFloat(items.count))
return items[safe: itemIndex]
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/BraveWallet/Crypto/Portfolio/PortfolioView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct PortfolioView: View {
@Environment(\.buySendSwapDestination)
private var buySendSwapDestination: Binding<BuySendSwapDestination?>

@State private var selectedContent: PortfolioSegmentedControl.SelectedContent = .assets
@State private var selectedContent: PortfolioSegmentedControl.Item = .assets
@ObservedObject private var isShowingNFTsTab = Preferences.Wallet.isShowingNFTsTab

var body: some View {
Expand Down