Files
blob-menu/Sources/Views/BlobMenuView.swift

163 lines
6.0 KiB
Swift

//
// BlobMenuView.swift
// BlobMenu
//
// Created by Igor K. on 27.04.2020.
// Copyright © 2020 Ramotion. All rights reserved.
//
import SwiftUI
public struct BlobMenuView: View {
private let configuration: BlobMenuConfiguration
@ObservedObject private var viewModel: BlobMenuModel
public init(model: BlobMenuModel,
configuration: BlobMenuConfiguration = .default) {
self.viewModel = model
self.configuration = configuration
UIWindow.current?.addGesture(type: .tap) {[weak model] _ in
model?.isBlobMenuItemsVisible = false
model?.isOpened = false
}
}
public var body: some View {
ZStack(alignment: .center) {
HStack {
Spacer()
self.background.overlay(self.itemsView)
Spacer().size(width: self.viewModel.isOpened ? nil : Theme.padding)
}
.backgroundPreferenceValue(BackgroundPreferenceKey.self) { p in
GeometryReader { geometry in
self.createStickyView(geometry: geometry, preferences: p)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
HStack {
Spacer()
HamburgerView(isOpened: viewModel.isOpened, color: configuration.hamburgerColor)
Spacer().size(width: Theme.padding)
}
}
.frame(height: Theme.height)
}
private func createStickyView(geometry: GeometryProxy, preferences: [BackgroundPreferenceData]) -> some View {
guard !self.viewModel.isBlobMenuItemsVisible else {
return AnyView(Color.clear)
}
let p = preferences.first
let f = geometry.frame(in: .local)
let b = p != nil ? geometry[p!.bounds] : .zero
let r = Theme.closedSize.height / 2
let w = f.width - b.maxX + r
let base = CGRect(x: f.width, y: f.minY, width: 50, height: f.height)
let effectView = StickyEffectShape(baseRect: base, figureRect: b, figureCornerRadius: r, avulsionDistance: Theme.stickyEffectAvulsionDistance)
return effectView
.fill(configuration.backgroundColor)
.frame(size: CGSize(width: w, height: f.height))
.asAnyView
}
private var background: some View {
BackgroundView(color: configuration.backgroundColor)
.cornerRadius(Theme.closedSize.height / 2)
.keyframes(size: Theme.backgroundSizeKeyframes(isOpened: viewModel.isOpened, items: viewModel.items), progress: viewModel.isOpened ? 1 : 0)
.animation(Animation.interpolatingSpring(mass: 1, stiffness: 170, damping: 15, initialVelocity: 1).delay(viewModel.isOpened ? 0.12 : 0))
.onAnimationCompleted(condition: viewModel.isOpened) {
self.viewModel.isBlobMenuItemsVisible = true
}
.onTapGesture {
withAnimation {
self.viewModel.isOpened = true
}
}
.allowsHitTesting(!self.viewModel.isOpened)
}
private var itemsView: some View {
Group {
if Theme.isScrollable(items: viewModel.items) {
ScrollView(.horizontal, showsIndicators: false) { itemsContent }
} else {
itemsContent
}
}
.clipShape(Capsule(style: .circular))
.animation(Animation.easeInOut(duration: 0.35).delay(0.15))
.allowsHitTesting(self.viewModel.isBlobMenuItemsVisible)
}
private var itemsContent: some View {
HStack(spacing: 20) {
ForEach(viewModel.items.enumeratedArray(), id: \.element) { index, item in
BlobMenuItemView(item: item,
isSelected: self.viewModel.selectedIndex == index,
isOpened: self.viewModel.isBlobMenuItemsVisible,
selectionColor: self.configuration.selectionColor)
.onTapGesture {
self.viewModel.selectedIndex = index
}
}
}
}
}
//MARK: - Theme
extension BlobMenuView {
enum Theme {
static let height: CGFloat = 60
static let padding: CGFloat = 10
static let BlobMenuItemsSpace: CGFloat = 20
static let stickyEffectAvulsionDistance: CGFloat = 120
static let closedSize = CGSize(width: 60, height: height)
static let collapsedSize = CGSize(width: 90, height: 70)
static let maxOpenSize = CGSize(width: UIScreen.main.bounds.width - 60, height: height)
private static func itemsSize(items: [BlobMenuItem]) -> CGSize {
let w = CGFloat(items.count) * BlobMenuItemView.Theme.size.width + CGFloat(items.count - 1) * BlobMenuItemsSpace
return CGSize(width: w, height: height)
}
static func openedSize(items: [BlobMenuItem]) -> CGSize {
if isScrollable(items: items) {
return maxOpenSize
} else {
return itemsSize(items: items)
}
}
static func isScrollable(items: [BlobMenuItem]) -> Bool {
return itemsSize(items: items).width > maxOpenSize.width
}
static func backgroundSizeKeyframes(isOpened: Bool, items: [BlobMenuItem]) -> [Keyframe<CGSize>] {
if isOpened {
//will use during opening animation
return [
Keyframe<CGSize>(value: closedSize, progress: 0),
Keyframe<CGSize>(value: openedSize(items: items), progress: 1)
]
} else {
//will use during closing animation
return [
Keyframe<CGSize>(value: closedSize, progress: 0),
Keyframe<CGSize>(value: collapsedSize, progress: 0.3),
Keyframe<CGSize>(value: openedSize(items: items), progress: 1)
]
}
}
}
}