mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-25 22:54:29 +00:00
implement UIViewPropertyAnimator
This commit is contained in:
@@ -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)
|
||||
/// ]
|
||||
/// )
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
Reference in New Issue
Block a user