implement UIViewPropertyAnimator

This commit is contained in:
Levitating Pineapple
2024-09-25 18:55:48 +03:00
parent d59fdb1e09
commit f28a5b6e92
5 changed files with 205 additions and 165 deletions
@@ -47,22 +47,24 @@ struct ChatListView: View {
}
private var viewBody: some View {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { active in
if !active { chatModel.chatId = nil }
}
),
destination: chatView
) { chatListView }
}
.modifier(
SwiftUISheet(height: 400, isPresented: $userPickerShown) {
ZStack {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { active in
if !active { chatModel.chatId = nil }
}
),
destination: chatView
) { chatListView }
}
SheetRepresentable(isPresented: $userPickerShown) {
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
}
)
.allowsHitTesting(userPickerShown)
.ignoresSafeArea()
}
.sheet(item: $activeUserPickerSheet) { sheet in
if let currentUser = chatModel.currentUser {
switch sheet {
@@ -109,7 +109,7 @@ struct UserPicker: View {
}
}
}
.modifier(ThemedBackground(grouped: true))
// .modifier(ThemedBackground(grouped: true))
.disabled(switchingProfile)
}
@@ -133,11 +133,14 @@ struct UserPicker: View {
.clipShape(sectionShape)
.onTapGesture {
switchingProfile = true
userPickerShown = false
Task {
do {
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
await MainActor.run { switchingProfile = false }
await MainActor.run {
switchingProfile = false
userPickerShown = false
}
} catch {
await MainActor.run {
switchingProfile = false
@@ -0,0 +1,179 @@
//
// SwiftUISheet.swift
// SimpleX (iOS)
//
// Created by user on 23/09/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let sheetAnimationDuration: Double = 0.3
struct SheetRepresentable<Content: View>: UIViewControllerRepresentable {
@Binding var isPresented: Bool
@ViewBuilder let content: () -> Content
func makeUIViewController(context: Context) -> Controller<Content> {
Controller(content: content(), representer: self)
}
func updateUIViewController(_ sheetController: Controller<Content>, context: Context) {
sheetController.animate(isPresented: isPresented)
sheetController.hostingController.rootView = content()
}
class Controller<Content: View>: UIViewController {
let hostingController: UIHostingController<Content>
private let animator = UIViewPropertyAnimator(duration: sheetAnimationDuration, curve: .easeInOut)
private let representer: SheetRepresentable<Content>
private var retainedFraction: CGFloat = 0
init(content: Content, representer: SheetRepresentable<Content>) {
self.representer = representer
self.hostingController = UIHostingController(rootView: content)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) missing") }
func animate(isPresented: Bool) {
let sheetDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0)
if isPresented || !sheetDismissed {
animator.pauseAnimation()
animator.isReversed = !isPresented
animator.continueAnimation(withTimingParameters: nil, durationFactor: 1)
}
}
override func viewDidLoad() {
view.backgroundColor = .clear
view.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(tap(gesture:)))
)
addChild(hostingController)
hostingController.didMove(toParent: self)
if let sheet = hostingController.view {
sheet.backgroundColor = UIColor { traits in
let elevated = switch traits.userInterfaceStyle {
case .dark: elevatedSystemGroupedBackground(.dark)
default: elevatedSystemGroupedBackground(.light)
}
return elevated.cgColor.map { UIColor(cgColor: $0) }
?? .secondarySystemBackground
}
sheet.layer.cornerRadius = 10
sheet.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
sheet.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:))))
sheet.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(sheet)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.bottomAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
animator.pausesOnCompletion = true
animator.scrubsLinearly = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.animator.addAnimations {
sheet.transform = CGAffineTransform(translationX: 0, y: -sheet.frame.height)
self?.view.backgroundColor = .black.withAlphaComponent(0.3)
}
}
}
}
@objc
func pan(gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .began:
animator.isReversed = false
animator.pauseAnimation()
retainedFraction = animator.fractionComplete
case .changed:
animator.fractionComplete = retainedFraction - gesture.translation(in: view).y / hostingController.view.frame.height
case .ended, .cancelled:
let velocity = gesture.velocity(in: view).y
animator.isReversed = velocity.sign == .plus
let defaultVelocity = hostingController.view.frame.height / sheetAnimationDuration
let fractionRemaining = 1 - animator.fractionComplete
let durationFactor = min(fractionRemaining / (abs(velocity) / defaultVelocity), 1)
animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor)
DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) {
self.representer.isPresented = !self.animator.isReversed
}
default: break
}
}
@objc
func tap(gesture: UITapGestureRecognizer) {
switch gesture.state {
case .ended:
representer.isPresented = false
default: break
}
}
}
}
// MARK: Sheet Colors
func elevatedSystemGroupedBackground(_ colorScheme: ColorScheme) -> Color {
switch colorScheme {
case .dark: Color(0xFF1C1C1E)
default: Color(0xFFF2F2F7)
}
}
func elevatedSecondarySystemGroupedBackground(_ colorScheme: ColorScheme) -> Color {
switch colorScheme {
case .dark: Color(0xFF2C2C2E)
default: Color(0xFFFFFFFF)
}
}
/// # Extracting Sheet Colors Programatically
///
/// System colors are returned dynamically, depending on the context:
///
/// struct ColorResolverView: View {
/// @Environment(\.self) var environment
/// let colors: [Color]
///
/// var body: some View {
/// HStack {
/// column.environment(\.colorScheme, .dark)
/// column.environment(\.colorScheme, .light)
/// }
/// }
///
/// var column: some View {
/// VStack {
/// ForEach(colors, id: \.self) {
/// Text("\($0.resolve(in: environment))")
/// .frame(maxWidth: .infinity, maxHeight: .infinity)
/// .background($0)
/// }
/// }
/// }
/// }
///
/// Place `ColorResolverView` inside a sheet to acquire elevated color versions:
///
/// struct ContentView: View {
/// var body: some View {
/// EmptyView()
/// .sheet(isPresented: .constant(true)) {
/// ColorResolverView(
/// colors: [
/// Color(.systemGroupedBackground),
/// Color(.secondarySystemGroupedBackground)
/// ]
/// )
/// }
/// }
/// }
@@ -1,144 +0,0 @@
//
// SwiftUISheet.swift
// SimpleX (iOS)
//
// Created by user on 23/09/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct SwiftUISheet<SheetContent: View>: ViewModifier {
let height: Double
@Binding var isPresented: Bool
@ViewBuilder let sheetContent: () -> SheetContent
@Environment(\.colorScheme) private var colorScheme: ColorScheme
// Represents offset relative to the height of the sheet
// - 0: Collapsed
// - 1: Fully expanded
@State private var relativeOffset: Double = 0
private let radius: Double = 16
func body(content: Content) -> some View {
let safeAreaInset: Double =
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.safeAreaInsets.bottom
} else { 0 }
let sheetHeight = height + safeAreaInset
ZStack {
content
Color.black.opacity(0.3 * relativeOffset)
.ignoresSafeArea()
.onTapGesture { isPresented = false }
sheetContent()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(elevatedSystemGroupedBackground(colorScheme))
.clipShape(ClipShape(bottomSafeAreaInset: safeAreaInset))
.frame(height: height)
.gesture(
DragGesture(minimumDistance: 8)
.onChanged {
relativeOffset = min(max(relativeOffset - $0.translation.height / sheetHeight, 0), 1)
}
.onEnded { _ in
let ip = relativeOffset > 0.6
if ip == isPresented {
animate()
} else {
isPresented = ip
}
}
)
.frame(maxHeight: .infinity, alignment: .bottom)
.offset(y: sheetHeight - sheetHeight * relativeOffset)
}
.onChange(of: isPresented) { _ in animate() }
}
private func animate() {
let newOffset: Double = isPresented ? 1 : 0
let distance: Double = abs(newOffset - relativeOffset)
if distance != 0 {
withAnimation(
.easeOut(duration: max(0.1, distance * 0.3))
) { relativeOffset = isPresented ? 1 : 0 }
}
}
struct ClipShape: Shape {
let bottomSafeAreaInset: Double
func path(in rect: CGRect) -> Path {
Path(
UIBezierPath(
roundedRect: CGRect(
origin: .zero,
size: CGSize(width: rect.width, height: rect.height + bottomSafeAreaInset)
),
byRoundingCorners: [.topLeft, .topRight],
cornerRadii: CGSize(width: 10, height: 10)
).cgPath
)
}
}
}
func elevatedSystemGroupedBackground(_ colorScheme: ColorScheme) -> Color {
switch colorScheme {
case .dark: Color(0xFF1C1C1E)
default: Color(0xFFF2F2F7)
}
}
func elevatedSecondarySystemGroupedBackground(_ colorScheme: ColorScheme) -> Color {
switch colorScheme {
case .dark: Color(0xFF2C2C2E)
default: Color(0xFFFFFFFF)
}
}
/// # Extracting Sheet Colors Programatically
///
/// System colors are returned dynamically, depending on the context:
///
/// struct ColorResolverView: View {
/// @Environment(\.self) var environment
/// let colors: [Color]
///
/// var body: some View {
/// HStack {
/// column.environment(\.colorScheme, .dark)
/// column.environment(\.colorScheme, .light)
/// }
/// }
///
/// var column: some View {
/// VStack {
/// ForEach(colors, id: \.self) {
/// Text("\($0.resolve(in: environment))")
/// .frame(maxWidth: .infinity, maxHeight: .infinity)
/// .background($0)
/// }
/// }
/// }
/// }
///
/// Place `ColorResolverView` inside a sheet to acquire elevated color versions:
///
/// struct ContentView: View {
/// var body: some View {
/// EmptyView()
/// .sheet(isPresented: .constant(true)) {
/// ColorResolverView(
/// colors: [
/// Color(.systemGroupedBackground),
/// Color(.secondarySystemGroupedBackground)
/// ]
/// )
/// }
/// }
/// }
+4 -4
View File
@@ -211,7 +211,7 @@
CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; };
CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; };
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; };
CEFB2EDF2CA1BCC7004B1ECE /* SwiftUISheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SwiftUISheet.swift */; };
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */; };
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; };
@@ -554,7 +554,7 @@
CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = "<group>"; };
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = "<group>"; };
CEFB2EDE2CA1BCC7004B1ECE /* SwiftUISheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUISheet.swift; sourceTree = "<group>"; };
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetRepresentable.swift; sourceTree = "<group>"; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
@@ -801,7 +801,7 @@
CE7548092C622630009579B7 /* SwipeLabel.swift */,
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */,
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */,
CEFB2EDE2CA1BCC7004B1ECE /* SwiftUISheet.swift */,
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -1452,7 +1452,7 @@
5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */,
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */,
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
CEFB2EDF2CA1BCC7004B1ECE /* SwiftUISheet.swift in Sources */,
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,