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

Commit

Permalink
Fix #8548: Refactor PortfolioSegmentedControl to be reusable (#8549)
Browse files Browse the repository at this point in the history
  • Loading branch information
StephenHeaps committed Dec 13, 2023
1 parent 7691d6b commit 50f3e3c
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 87 deletions.
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

0 comments on commit 50f3e3c

Please sign in to comment.