Zara replica — part 4

Custom sheet & Multiple gestures

Custom sheet

import SwiftUIstruct CustomSheet<Content: View>: View {    // 1
enum SheetState {
case expanded, folded
}
@State private var yOffset: CGFloat = Sizes.screenHeight
@State private var sheetState: SheetState = .folded
// 2
@Binding var progress: CGFloat
// 3
var foldedHeight: CGFloat
var expandedHeight: CGFloat
// 4
var content: (() -> Content)
init(progress: Binding<CGFloat>,foldedHeight: CGFloat = 300, expandedHeight: CGFloat = Sizes.screenHeight, @ViewBuilder content: @escaping (() -> Content)) {
self._progress = progress
self.content = content
self.foldedHeight = foldedHeight
self.expandedHeight = expandedHeight
}
var body: some View {
Group {
createContent()
}.frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.onAppear(perform: {
yOffset = deltaHeight()
})
}
fileprivate func createContent() -> some View {
Color.background
.overlay(content(), alignment: .top)
.frame(height: expandedHeight, alignment: .bottom)
.offset(x: 0, y: yOffset)
.gesture(
DragGesture().onChanged({ drag in
handleOnDrag(drag)
}).onEnded({ drag in
handleOnEnded(drag)
})
).animation(.easeInOut, value: self.sheetState)
}
// 6
private func handleOnDrag(_ drag: DragGesture.Value) {
self.yOffset = sheetState == .expanded
? max(drag.translation.height, 0)
: min(drag.location.y, deltaHeight() )
self.progress = yOffset / deltaHeight()
}
// 7
private func handleOnEnded(_ drag: _ChangedGesture<DragGesture>.Value) {
switch sheetState {
case .expanded:
resetValues(for: drag.translation.height > foldedHeight ? .folded : .expanded)
case .folded:
resetValues(for: abs(drag.location.y) < deltaHeight() ? .expanded : .folded)
}
}
private func resetValues(for state: SheetState) {
self.yOffset = state == .folded ? deltaHeight() : 0
self.sheetState = state
self.progress = state == .folded ? 1 : 0
}
private func deltaHeight() -> CGFloat {
expandedHeight - foldedHeight
}
}
struct CustomSheet_Previews: PreviewProvider {
static var previews: some View {
CustomSheet(progress: .constant(0), foldedHeight: 200, expandedHeight: UIScreen.main.bounds.height - 200) {
Text("This is a custom sheet")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.blue)
.edgesIgnoringSafeArea(.all)
}
}
}

Image Page View

import SwiftUI
import KingfisherSwiftUI
struct ImageView: View { var image: String
private let screenWidth = Sizes.screenWidth
private let screenHeight = Sizes.screenHeight
var body: some View {
URL(string: image).map {
KFImage($0)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: screenWidth, height: screenHeight)
}
}
}
struct ImageView_Previews: PreviewProvider {
static var previews: some View {
ImageView(image: "zara-textured-check-shirt 3.JPG")
}
}

Product ImageView

import SwiftUI
import KingfisherSwiftUI
struct ProductImageView: View { var imgUrl: String
// 1
var animation: Namespace.ID
var onDismissPreview = {}
@State private var zoomLevel: CGFloat = 1
@State private var offset: CGSize = .zero
var body: some View {
ImageView(imgUrl: imgUrl)
.scaleEffect(self.zoomLevel)
.offset(self.offset)
.gesture(handleDragGesture())
.gesture(handleMagnificationGesture())
.onTapGesture(count: 2, perform: handleDoubleTapGesture)
.onTapGesture(count: 1, perform: handleSingleTapGesture)
.background(Color.background)
.edgesIgnoringSafeArea(.all)
.animation(.easeIn, value: self.zoomLevel)
}
private func handleDragGesture() -> _EndedGesture<_ChangedGesture<DragGesture>> {
return DragGesture()
.onChanged({ value in
if zoomLevel > 1 {
self.offset = value.translation
}
}).onEnded({ value in
withAnimation {
self.offset = .zero
}
})
}
private func handleMagnificationGesture() -> _EndedGesture<_ChangedGesture<MagnificationGesture>> {
return MagnificationGesture().onChanged({ (value) in
self.zoomLevel = value
}).onEnded { value in
if value < CGFloat(1) {
self.zoomLevel = 1
}
}
}
private func handleSingleTapGesture() {
if zoomLevel == 1 {
onDismissPreview()
}
}
private func handleDoubleTapGesture() {
if zoomLevel > 1 {
zoomLevel = 1
} else {
zoomLevel = 2
}
}
}
struct ProductImageView_Previews: PreviewProvider {
@Namespace static var animation
static var previews: some View {
ProductImageView(imgUrl: "", animation: animation)
}
}

Product detail content

import SwiftUIstruct ProductDetailContent: View {    var product: Product    var body: some View {
VStack(alignment: .leading, spacing: 30) {
VStack(alignment: .leading, spacing: 10) {
Text(product.title)
.font(.system(size: 15, weight: Font.Weight.bold, design: Font.Design.default))
Text(product.price)
.font(.system(size: 14, weight: Font.Weight.light, design: Font.Design.default))
HStack {
BorderedButton(text: "ADD") {}
Spacer()
HStack(spacing: 20) {
IconButton(icon: "square.and.arrow.up") {}
IconButton(icon: "bookmark") {}
IconButton(icon: "bag") {}
}
}
}
Text(product.description).font(.system(size: 14, weight: Font.Weight.light, design: Font.Design.default)) VStack(alignment: .leading, spacing: 20) {
ArrowButtton(text: "COMPOSITION AND CARE") {}
ArrowButtton(text: "IN-STORE AVAILABILITY") {}
ArrowButtton(text: "SHIPPING AND RETURNS") {}
ArrowButtton(text: "CAN WE HELP YOU") {}
}
}.padding(.all, 20)
}
}
struct ProductDetailContent_Previews: PreviewProvider {
static var previews: some View {
ProductDetailContent(product: Product.kids.first!)
}
}

Clear NavBar

struct ClearNavBar: View {    var opacity: Double
var onDismiss = {}
var body: some View {
HStack {
Button(action: onDismiss, label: {
Image(systemName: "xmark")
.font(.system(size: 25, weight: Font.Weight.light, design: Font.Design.default))
.foregroundColor(.gray)
.padding(10)
})
Spacer()
}.padding(.horizontal, 20).frame(height: UIScreen.main.bounds.width * 0.22, alignment: .bottom)
.background(Color.background.opacity(opacity)).edgesIgnoringSafeArea(.top) }
}
struct ClearNavBar_Previews: PreviewProvider {
static var previews: some View {
ClearNavBar(opacity: 1)
}
}

Product Detail Screen

import SwiftUI
import KingfisherSwiftUI
struct ProductDetailScreen: View { var product: Product
var onDismiss = {}
@State private var progress: CGFloat = 1
@State private var selectedIndex: Int?
@Namespace private var animationprivate let foldedHeight: CGFloat = 150
private let expandedHeight = Sizes.screenHeight - UIScreen.main.bounds.width * 0.22
var body: some View { ZStack(alignment: .top) {
createPagingController()
createNavBarView()
if selectedIndex == nil {
createCustomSheet()
} else {
createProductImageView()
}
}.edgesIgnoringSafeArea(.all)
.animation(.linear, value: selectedIndex)
} fileprivate func createCustomSheet() -> some View {
CustomSheet(progress: self.$progress, foldedHeight: foldedHeight, expandedHeight: expandedHeight) {
ProductDetailContent(product: product)
}.transition(.move(edge: .bottom))
}
fileprivate func createProductImageView() -> ProductImageView {
let images = product.images
return ProductImageView(imgUrl: images[selectedIndex!], animation: animation)
{ self.selectedIndex = nil }
}
fileprivate func createPagingController() -> some View {
PagingController(viewControllers:
product.images.map({
UIHostingController(rootView:
ImageView(imgUrl: $0)
.matchedGeometryEffect(id: $0, in: animation)
)}), selectedIndex: self.$selectedIndex)
}
fileprivate func createNavBarView() -> some View {
ClearNavBar(opacity: Double(1 - progress), onDismiss: onDismiss)
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top)
}
}struct DetailScreen_Previews: PreviewProvider {
static var previews: some View {
ProductDetailScreen(product: Product.men.first!)
}
}

Building real world apps.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store