Created
June 4, 2026 07:32
-
-
Save paulocoutinhox/69d35e72232a560fe6fdacdd6c85b752 to your computer and use it in GitHub Desktop.
Product Details Test in SwiftUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| struct ProductDetailsScreen: View { | |
| @State private var selectedImage = 0 | |
| @State private var selectedColor = "Black" | |
| @State private var selectedSize = "M" | |
| @State private var quantity = 1 | |
| @State private var isFavorite = false | |
| @State private var selectedSection = "Details" | |
| private let colors = ["Black", "Blue", "Green", "Orange"] | |
| private let sizes = ["XS", "S", "M", "L", "XL"] | |
| private let sections = ["Details", "Features", "Reviews"] | |
| private var total: Double { | |
| Double(quantity) * 89.90 | |
| } | |
| var body: some View { | |
| ZStack { | |
| Color(.systemGroupedBackground) | |
| .ignoresSafeArea() | |
| ScrollView(showsIndicators: false) { | |
| VStack(spacing: 16) { | |
| headerGallery | |
| mainInfoCard | |
| optionCard | |
| sectionSelector | |
| dynamicSectionCard | |
| deliveryCard | |
| sellerCard | |
| similarProductsSection | |
| Spacer() | |
| .frame(height: 100) | |
| } | |
| .padding(.bottom, 20) | |
| } | |
| } | |
| .safeAreaInset(edge: .bottom) { | |
| bottomBar | |
| } | |
| } | |
| private var headerGallery: some View { | |
| ZStack(alignment: .top) { | |
| TabView(selection: $selectedImage) { | |
| ForEach(0..<4, id: \.self) { index in | |
| ZStack { | |
| LinearGradient( | |
| colors: [ | |
| Color(.systemGray5), | |
| Color(.systemGray6) | |
| ], | |
| startPoint: .topLeading, | |
| endPoint: .bottomTrailing | |
| ) | |
| VStack(spacing: 18) { | |
| Image(systemName: "backpack.fill") | |
| .resizable() | |
| .scaledToFit() | |
| .frame(width: 150, height: 150) | |
| .foregroundStyle(.secondary) | |
| Text("Product Preview \(index + 1)") | |
| .font(.headline) | |
| .foregroundStyle(.secondary) | |
| } | |
| } | |
| .tag(index) | |
| } | |
| } | |
| .tabViewStyle(.page(indexDisplayMode: .never)) | |
| .frame(height: 360) | |
| VStack { | |
| HStack { | |
| Button { | |
| // back action | |
| } label: { | |
| Image(systemName: "chevron.left") | |
| .font(.headline) | |
| .foregroundStyle(.primary) | |
| .frame(width: 42, height: 42) | |
| .background(.ultraThinMaterial) | |
| .clipShape(Circle()) | |
| } | |
| Spacer() | |
| Button { | |
| isFavorite.toggle() | |
| } label: { | |
| Image(systemName: isFavorite ? "heart.fill" : "heart") | |
| .font(.headline) | |
| .foregroundStyle(isFavorite ? .red : .primary) | |
| .frame(width: 42, height: 42) | |
| .background(.ultraThinMaterial) | |
| .clipShape(Circle()) | |
| } | |
| } | |
| .padding(.horizontal, 20) | |
| .padding(.top, 16) | |
| Spacer() | |
| HStack(spacing: 8) { | |
| ForEach(0..<4, id: \.self) { index in | |
| Capsule() | |
| .fill(selectedImage == index ? Color.primary : Color.secondary.opacity(0.3)) | |
| .frame(width: selectedImage == index ? 24 : 8, height: 8) | |
| } | |
| } | |
| .padding(.bottom, 18) | |
| } | |
| } | |
| } | |
| private var mainInfoCard: some View { | |
| VStack(alignment: .leading, spacing: 18) { | |
| HStack(alignment: .top) { | |
| VStack(alignment: .leading, spacing: 8) { | |
| Text("Premium Urban Backpack") | |
| .font(.title2) | |
| .fontWeight(.bold) | |
| Text("Waterproof backpack with laptop space, hidden pocket and modern design.") | |
| .font(.subheadline) | |
| .foregroundStyle(.secondary) | |
| .lineLimit(2) | |
| } | |
| Spacer() | |
| VStack(alignment: .trailing, spacing: 5) { | |
| HStack(spacing: 4) { | |
| Image(systemName: "star.fill") | |
| .foregroundStyle(.yellow) | |
| Text("4.8") | |
| .fontWeight(.bold) | |
| } | |
| .font(.subheadline) | |
| Text("2.4k reviews") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| } | |
| HStack(alignment: .bottom, spacing: 8) { | |
| Text("$89.90") | |
| .font(.largeTitle) | |
| .fontWeight(.heavy) | |
| Text("$119.90") | |
| .font(.headline) | |
| .foregroundStyle(.secondary) | |
| .strikethrough() | |
| Spacer() | |
| Text("-25%") | |
| .font(.caption) | |
| .fontWeight(.bold) | |
| .foregroundStyle(.white) | |
| .padding(.horizontal, 12) | |
| .padding(.vertical, 7) | |
| .background(.red) | |
| .clipShape(Capsule()) | |
| } | |
| HStack(spacing: 12) { | |
| productBadge(icon: "truck.box.fill", title: "Free", subtitle: "Shipping") | |
| productBadge(icon: "arrow.uturn.backward", title: "30 days", subtitle: "Return") | |
| productBadge(icon: "shield.checkered", title: "1 year", subtitle: "Warranty") | |
| } | |
| } | |
| .cardStyle() | |
| .padding(.horizontal, 16) | |
| .padding(.top, -8) | |
| } | |
| private var optionCard: some View { | |
| VStack(alignment: .leading, spacing: 24) { | |
| VStack(alignment: .leading, spacing: 12) { | |
| HStack { | |
| Text("Color") | |
| .font(.headline) | |
| Spacer() | |
| Text(selectedColor) | |
| .font(.subheadline) | |
| .foregroundStyle(.secondary) | |
| } | |
| HStack(spacing: 12) { | |
| ForEach(colors, id: \.self) { color in | |
| Button { | |
| selectedColor = color | |
| } label: { | |
| Text(color) | |
| .font(.caption) | |
| .fontWeight(.bold) | |
| .foregroundStyle(selectedColor == color ? .white : .primary) | |
| .padding(.horizontal, 14) | |
| .padding(.vertical, 10) | |
| .background(selectedColor == color ? Color.primary : Color(.secondarySystemGroupedBackground)) | |
| .clipShape(Capsule()) | |
| } | |
| } | |
| } | |
| } | |
| VStack(alignment: .leading, spacing: 12) { | |
| HStack { | |
| Text("Size") | |
| .font(.headline) | |
| Spacer() | |
| Button("Size guide") { | |
| // size guide action | |
| } | |
| .font(.subheadline) | |
| .fontWeight(.semibold) | |
| } | |
| HStack(spacing: 10) { | |
| ForEach(sizes, id: \.self) { size in | |
| Button { | |
| selectedSize = size | |
| } label: { | |
| Text(size) | |
| .font(.subheadline) | |
| .fontWeight(.bold) | |
| .foregroundStyle(selectedSize == size ? .white : .primary) | |
| .frame(width: 48, height: 42) | |
| .background(selectedSize == size ? Color.primary : Color(.secondarySystemGroupedBackground)) | |
| .clipShape(RoundedRectangle(cornerRadius: 14)) | |
| } | |
| } | |
| } | |
| } | |
| HStack { | |
| Text("Quantity") | |
| .font(.headline) | |
| Spacer() | |
| HStack(spacing: 16) { | |
| Button { | |
| quantity = max(1, quantity - 1) | |
| } label: { | |
| Image(systemName: "minus") | |
| .frame(width: 38, height: 38) | |
| .background(Color(.secondarySystemGroupedBackground)) | |
| .clipShape(Circle()) | |
| } | |
| Text("\(quantity)") | |
| .font(.headline) | |
| .frame(width: 24) | |
| Button { | |
| quantity += 1 | |
| } label: { | |
| Image(systemName: "plus") | |
| .frame(width: 38, height: 38) | |
| .background(Color(.secondarySystemGroupedBackground)) | |
| .clipShape(Circle()) | |
| } | |
| } | |
| } | |
| } | |
| .cardStyle() | |
| .padding(.horizontal, 16) | |
| } | |
| private var sectionSelector: some View { | |
| HStack(spacing: 8) { | |
| ForEach(sections, id: \.self) { section in | |
| Button { | |
| selectedSection = section | |
| } label: { | |
| Text(section) | |
| .font(.subheadline) | |
| .fontWeight(.bold) | |
| .foregroundStyle(selectedSection == section ? .white : .secondary) | |
| .frame(maxWidth: .infinity) | |
| .padding(.vertical, 12) | |
| .background(selectedSection == section ? Color.primary : Color.clear) | |
| .clipShape(Capsule()) | |
| } | |
| } | |
| } | |
| .padding(6) | |
| .background(Color(.systemBackground)) | |
| .clipShape(Capsule()) | |
| .padding(.horizontal, 16) | |
| } | |
| @ViewBuilder | |
| private var dynamicSectionCard: some View { | |
| if selectedSection == "Details" { | |
| detailsCard | |
| } else if selectedSection == "Features" { | |
| featuresCard | |
| } else { | |
| reviewsCard | |
| } | |
| } | |
| private var detailsCard: some View { | |
| VStack(alignment: .leading, spacing: 12) { | |
| Text("Description") | |
| .font(.headline) | |
| Text("Designed for daily use, work and travel. It includes a waterproof layer, padded laptop compartment, anti-theft pocket and ergonomic back support.") | |
| .font(.subheadline) | |
| .foregroundStyle(.secondary) | |
| .lineSpacing(4) | |
| } | |
| .cardStyle() | |
| .padding(.horizontal, 16) | |
| } | |
| private var featuresCard: some View { | |
| LazyVGrid( | |
| columns: [ | |
| GridItem(.flexible()), | |
| GridItem(.flexible()) | |
| ], | |
| spacing: 12 | |
| ) { | |
| featureItem(icon: "drop.fill", title: "Waterproof") | |
| featureItem(icon: "laptopcomputer", title: "Laptop 16”") | |
| featureItem(icon: "lock.fill", title: "Anti-theft") | |
| featureItem(icon: "battery.100", title: "USB Port") | |
| featureItem(icon: "leaf.fill", title: "Eco fabric") | |
| featureItem(icon: "scalemass.fill", title: "Lightweight") | |
| } | |
| .cardStyle() | |
| .padding(.horizontal, 16) | |
| } | |
| private var reviewsCard: some View { | |
| VStack(spacing: 14) { | |
| reviewItem(name: "Amanda Silva", text: "Excellent quality and beautiful finish.") | |
| reviewItem(name: "Lucas Martins", text: "Very comfortable for daily use.") | |
| } | |
| .cardStyle() | |
| .padding(.horizontal, 16) | |
| } | |
| private var deliveryCard: some View { | |
| VStack(alignment: .leading, spacing: 16) { | |
| Text("Delivery Options") | |
| .font(.headline) | |
| deliveryRow(icon: "truck.box.fill", title: "Standard Delivery", subtitle: "2 to 5 business days", price: "Free") | |
| deliveryRow(icon: "bolt.car.fill", title: "Express Delivery", subtitle: "1 to 2 business days", price: "$12.90") | |
| deliveryRow(icon: "shippingbox.fill", title: "Store Pickup", subtitle: "Available today", price: "Free") | |
| } | |
| .cardStyle() | |
| .padding(.horizontal, 16) | |
| } | |
| private var sellerCard: some View { | |
| VStack(spacing: 16) { | |
| HStack(spacing: 14) { | |
| Image(systemName: "building.2.fill") | |
| .font(.title2) | |
| .foregroundStyle(.blue) | |
| .frame(width: 54, height: 54) | |
| .background(Color.blue.opacity(0.12)) | |
| .clipShape(Circle()) | |
| VStack(alignment: .leading, spacing: 4) { | |
| Text("Urban Gear Store") | |
| .font(.headline) | |
| Text("Verified seller · 98% positive feedback") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| Spacer() | |
| Image(systemName: "chevron.right") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| Divider() | |
| HStack { | |
| sellerMetric(value: "12k+", label: "Sales") | |
| sellerMetric(value: "4.9", label: "Rating") | |
| sellerMetric(value: "24h", label: "Support") | |
| } | |
| } | |
| .cardStyle() | |
| .padding(.horizontal, 16) | |
| } | |
| private var similarProductsSection: some View { | |
| VStack(alignment: .leading, spacing: 14) { | |
| Text("You may also like") | |
| .font(.headline) | |
| .padding(.horizontal, 16) | |
| ScrollView(.horizontal, showsIndicators: false) { | |
| HStack(spacing: 14) { | |
| similarProduct(title: "Travel Organizer", price: "$24.90", icon: "rectangle.grid.1x2.fill") | |
| similarProduct(title: "Laptop Sleeve", price: "$34.90", icon: "laptopcomputer") | |
| similarProduct(title: "Bottle Holder", price: "$14.90", icon: "waterbottle.fill") | |
| } | |
| .padding(.horizontal, 16) | |
| } | |
| } | |
| } | |
| private var bottomBar: some View { | |
| HStack(spacing: 14) { | |
| VStack(alignment: .leading, spacing: 2) { | |
| Text("Total") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| Text("$\(total, specifier: "%.2f")") | |
| .font(.title3) | |
| .fontWeight(.heavy) | |
| } | |
| Spacer() | |
| Button { | |
| // add to cart action | |
| } label: { | |
| Label("Add to Cart", systemImage: "cart.fill") | |
| .font(.headline) | |
| .fontWeight(.bold) | |
| .foregroundStyle(.white) | |
| .frame(height: 56) | |
| .padding(.horizontal, 26) | |
| .background(Color.primary) | |
| .clipShape(RoundedRectangle(cornerRadius: 18)) | |
| } | |
| } | |
| .padding(.horizontal, 18) | |
| .padding(.top, 12) | |
| .padding(.bottom, 10) | |
| .background(.ultraThinMaterial) | |
| } | |
| private func productBadge(icon: String, title: String, subtitle: String) -> some View { | |
| VStack(spacing: 6) { | |
| Image(systemName: icon) | |
| .font(.headline) | |
| Text(title) | |
| .font(.caption) | |
| .fontWeight(.bold) | |
| Text(subtitle) | |
| .font(.caption2) | |
| .foregroundStyle(.secondary) | |
| } | |
| .frame(maxWidth: .infinity) | |
| .padding(.vertical, 12) | |
| .background(Color(.secondarySystemGroupedBackground)) | |
| .clipShape(RoundedRectangle(cornerRadius: 16)) | |
| } | |
| private func featureItem(icon: String, title: String) -> some View { | |
| HStack(spacing: 10) { | |
| Image(systemName: icon) | |
| .foregroundStyle(.blue) | |
| .frame(width: 34, height: 34) | |
| .background(Color.blue.opacity(0.12)) | |
| .clipShape(Circle()) | |
| Text(title) | |
| .font(.subheadline) | |
| .fontWeight(.semibold) | |
| Spacer() | |
| } | |
| .padding(12) | |
| .background(Color(.secondarySystemGroupedBackground)) | |
| .clipShape(RoundedRectangle(cornerRadius: 16)) | |
| } | |
| private func deliveryRow(icon: String, title: String, subtitle: String, price: String) -> some View { | |
| HStack(spacing: 12) { | |
| Image(systemName: icon) | |
| .foregroundStyle(.purple) | |
| .frame(width: 44, height: 44) | |
| .background(Color.purple.opacity(0.12)) | |
| .clipShape(Circle()) | |
| VStack(alignment: .leading, spacing: 4) { | |
| Text(title) | |
| .font(.subheadline) | |
| .fontWeight(.bold) | |
| Text(subtitle) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| Spacer() | |
| Text(price) | |
| .font(.subheadline) | |
| .fontWeight(.bold) | |
| } | |
| } | |
| private func reviewItem(name: String, text: String) -> some View { | |
| VStack(alignment: .leading, spacing: 10) { | |
| HStack { | |
| Circle() | |
| .fill(Color(.secondarySystemGroupedBackground)) | |
| .frame(width: 38, height: 38) | |
| .overlay { | |
| Text(String(name.prefix(1))) | |
| .fontWeight(.bold) | |
| } | |
| VStack(alignment: .leading, spacing: 3) { | |
| Text(name) | |
| .font(.subheadline) | |
| .fontWeight(.bold) | |
| HStack(spacing: 2) { | |
| ForEach(0..<5, id: \.self) { _ in | |
| Image(systemName: "star.fill") | |
| .font(.caption2) | |
| .foregroundStyle(.yellow) | |
| } | |
| } | |
| } | |
| Spacer() | |
| } | |
| Text(text) | |
| .font(.subheadline) | |
| .foregroundStyle(.secondary) | |
| } | |
| .padding(14) | |
| .background(Color(.secondarySystemGroupedBackground)) | |
| .clipShape(RoundedRectangle(cornerRadius: 18)) | |
| } | |
| private func sellerMetric(value: String, label: String) -> some View { | |
| VStack(spacing: 4) { | |
| Text(value) | |
| .font(.headline) | |
| .fontWeight(.bold) | |
| Text(label) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| .frame(maxWidth: .infinity) | |
| } | |
| private func similarProduct(title: String, price: String, icon: String) -> some View { | |
| VStack(alignment: .leading, spacing: 10) { | |
| ZStack { | |
| RoundedRectangle(cornerRadius: 18) | |
| .fill(Color(.secondarySystemGroupedBackground)) | |
| .frame(width: 140, height: 120) | |
| Image(systemName: icon) | |
| .font(.largeTitle) | |
| .foregroundStyle(.secondary) | |
| } | |
| Text(title) | |
| .font(.subheadline) | |
| .fontWeight(.bold) | |
| .lineLimit(1) | |
| Text(price) | |
| .font(.headline) | |
| .fontWeight(.heavy) | |
| } | |
| .frame(width: 140) | |
| .padding(12) | |
| .background(Color(.systemBackground)) | |
| .clipShape(RoundedRectangle(cornerRadius: 22)) | |
| } | |
| } | |
| private extension View { | |
| func cardStyle() -> some View { | |
| self | |
| .padding(20) | |
| .background(Color(.systemBackground)) | |
| .clipShape(RoundedRectangle(cornerRadius: 26)) | |
| } | |
| } | |
| #Preview { | |
| ProductDetailsScreen() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment