Last active
August 3, 2024 14:19
-
-
Save rusik/a7b18c9d3741e4851e2150137fa4dafd to your computer and use it in GitHub Desktop.
Implementation of half-pizza UI from Dodo Pizza on SwiftUI. Original UIKit implementation is here → https://habr.com/ru/company/dododev/blog/452876/
This file contains 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 | |
import Foundation | |
struct ContentView: View { | |
var body: some View { | |
HStack(spacing: 4) { | |
PizzaView(color: .blue, orientation: .left) | |
PizzaView(color: .red, orientation: .right) | |
} | |
} | |
} | |
struct PizzaView: View { | |
var color: Color = .gray | |
var models: [Int] = [0, 1, 2, 3, 4, 5] | |
var orientation: HalfCircle.Orientation = .right | |
private let widht: CGFloat = 300 | |
@State private var idx: Int = 0 | |
@State private var positions: [Int: CGFloat] = [:] | |
var body: some View { | |
PagerView(pageCount: models.count, currentIndex: $idx) { | |
ForEach(models, id: \.self) { idx in | |
let halfWidth = widht / 2 | |
let percent = min(1, max(0, (halfWidth - abs(positions[idx, default: 0] - halfWidth)) / halfWidth)) | |
let scale = 0.5 + percent * 0.5 | |
HalfCircle(orientation: orientation) | |
.frame(width: widht, height: widht) | |
.foregroundColor(color) | |
.rotationEffect(.degrees(270)) | |
.background( | |
GeometryReader { proxy in | |
Color.clear.preference( | |
key: PositionPreferenceKey.self, | |
value: [idx: proxy.frame(in: .named("view")).midY] | |
) | |
} | |
) | |
.scaleEffect(scale, anchor: orientation == .right ? .bottom : .top) | |
.opacity(scale) | |
} | |
} | |
.onPreferenceChange(PositionPreferenceKey.self) { | |
self.positions = $0 | |
} | |
.frame(width: widht, height: widht) | |
.rotationEffect(.degrees(90)) | |
.coordinateSpace(name: "view") | |
} | |
} | |
struct HalfCircle: Shape { | |
let orientation: Orientation | |
enum Orientation { | |
case left, right | |
} | |
func path(in rect: CGRect) -> Path { | |
var path = Path() | |
let radius = min(rect.width, rect.height) / 2 | |
let startPoint = CGPoint( | |
x: orientation == .right ? rect.minX : rect.maxX, | |
y: rect.minY | |
) | |
let center = CGPoint( | |
x: orientation == .right ? rect.minX : rect.maxX, | |
y: rect.midY | |
) | |
path.move(to: startPoint) | |
path.addArc( | |
center: center, | |
radius: radius, | |
startAngle: .degrees(90), | |
endAngle: .degrees(270), | |
clockwise: orientation == .right, | |
transform: .identity | |
) | |
return path | |
} | |
} | |
struct PagerView<Content: View>: View { | |
let pageCount: Int | |
@State var ignore: Bool = false | |
@Binding var currentIndex: Int { | |
didSet { | |
if (!ignore) { | |
currentFloatIndex = CGFloat(currentIndex) | |
} | |
} | |
} | |
@State var currentFloatIndex: CGFloat = 0 { | |
didSet { | |
ignore = true | |
currentIndex = min(max(Int(currentFloatIndex.rounded()), 0), self.pageCount - 1) | |
ignore = false | |
} | |
} | |
let content: Content | |
@GestureState private var offsetX: CGFloat = 0 | |
init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) { | |
self.pageCount = pageCount | |
self._currentIndex = currentIndex | |
self.content = content() | |
} | |
var body: some View { | |
GeometryReader { geometry in | |
HStack(spacing: 0) { | |
self.content.frame(width: geometry.size.width) | |
} | |
.frame(width: geometry.size.width, alignment: .leading) | |
.offset(x: -CGFloat(self.currentFloatIndex) * geometry.size.width) | |
.offset(x: self.offsetX) | |
.animation(.linear, value:offsetX) | |
.highPriorityGesture( | |
DragGesture().updating(self.$offsetX) { value, state, _ in | |
state = value.translation.width | |
} | |
.onEnded({ (value) in | |
let offset = value.translation.width / geometry.size.width | |
let offsetPredicted = value.predictedEndTranslation.width / geometry.size.width | |
let newIndex = CGFloat(self.currentFloatIndex) - offset | |
self.currentFloatIndex = newIndex | |
withAnimation(.easeOut) { | |
if(offsetPredicted < -0.5 && offset > -0.5) { | |
self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() + 1), 0), self.pageCount - 1)) | |
} else if (offsetPredicted > 0.5 && offset < 0.5) { | |
self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() - 1), 0), self.pageCount - 1)) | |
} else { | |
self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded()), 0), self.pageCount - 1)) | |
} | |
} | |
}) | |
) | |
} | |
.onChange(of: currentIndex, perform: { value in | |
withAnimation(.easeOut) { | |
currentFloatIndex = CGFloat(value) | |
} | |
}) | |
} | |
} | |
private struct PositionPreferenceKey: PreferenceKey { | |
static var defaultValue: [Int: CGFloat] = [:] | |
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { | |
for (key, val) in nextValue() { | |
value[key] = val | |
} | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment