I'm trying to recreate a Modal just like Safari in iOS13 in SwiftUI:
Here's what it looks like:
Does anyone know if this is possible in SwiftUI? I want to show a small half modal, with the option to drag to fullscreen, just like the sharing sheet.
Any advice is much appreciated!
In Swift 5.5 iOS 15+ and Mac Catalyst 15+ there is a
There is a new solution with adaptiveSheetPresentationController
https://developer.apple.com/documentation/uikit/uipopoverpresentationcontroller/3810055-adaptivesheetpresentationcontrol?changes=__4
#available(iOS 15.0, *)
struct CustomSheetParentView: View {
#State private var isPresented = false
var body: some View {
VStack{
Button("present sheet", action: {
isPresented.toggle()
}).adaptiveSheet(isPresented: $isPresented, detents: [.medium()], smallestUndimmedDetentIdentifier: .large){
Rectangle()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.foregroundColor(.clear)
.border(Color.blue, width: 3)
.overlay(Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
isPresented.toggle()
}
)
}
}
}
}
#available(iOS 15.0, *)
struct AdaptiveSheet<T: View>: ViewModifier {
let sheetContent: T
#Binding var isPresented: Bool
let detents : [UISheetPresentationController.Detent]
let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
let prefersScrollingExpandsWhenScrolledToEdge: Bool
let prefersEdgeAttachedInCompactHeight: Bool
init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, #ViewBuilder content: #escaping () -> T) {
self.sheetContent = content()
self.detents = detents
self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
self._isPresented = isPresented
}
func body(content: Content) -> some View {
ZStack{
content
CustomSheet_UI(isPresented: $isPresented, detents: detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {sheetContent}).frame(width: 0, height: 0)
}
}
}
#available(iOS 15.0, *)
extension View {
func adaptiveSheet<T: View>(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, #ViewBuilder content: #escaping () -> T)-> some View {
modifier(AdaptiveSheet(isPresented: isPresented, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: content))
}
}
#available(iOS 15.0, *)
struct CustomSheet_UI<Content: View>: UIViewControllerRepresentable {
let content: Content
#Binding var isPresented: Bool
let detents : [UISheetPresentationController.Detent]
let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
let prefersScrollingExpandsWhenScrolledToEdge: Bool
let prefersEdgeAttachedInCompactHeight: Bool
init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, #ViewBuilder content: #escaping () -> Content) {
self.content = content()
self.detents = detents
self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
self._isPresented = isPresented
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> CustomSheetViewController<Content> {
let vc = CustomSheetViewController(coordinator: context.coordinator, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {content})
return vc
}
func updateUIViewController(_ uiViewController: CustomSheetViewController<Content>, context: Context) {
if isPresented{
uiViewController.presentModalView()
}else{
uiViewController.dismissModalView()
}
}
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
var parent: CustomSheet_UI
init(_ parent: CustomSheet_UI) {
self.parent = parent
}
//Adjust the variable when the user dismisses with a swipe
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
if parent.isPresented{
parent.isPresented = false
}
}
}
}
#available(iOS 15.0, *)
class CustomSheetViewController<Content: View>: UIViewController {
let content: Content
let coordinator: CustomSheet_UI<Content>.Coordinator
let detents : [UISheetPresentationController.Detent]
let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
let prefersScrollingExpandsWhenScrolledToEdge: Bool
let prefersEdgeAttachedInCompactHeight: Bool
private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
init(coordinator: CustomSheet_UI<Content>.Coordinator, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, #ViewBuilder content: #escaping () -> Content) {
self.content = content()
self.coordinator = coordinator
self.detents = detents
self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
super.init(nibName: nil, bundle: .main)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func dismissModalView(){
dismiss(animated: true, completion: nil)
}
func presentModalView(){
let hostingController = UIHostingController(rootView: content)
hostingController.modalPresentationStyle = .popover
hostingController.presentationController?.delegate = coordinator as UIAdaptivePresentationControllerDelegate
hostingController.modalTransitionStyle = .coverVertical
if let hostPopover = hostingController.popoverPresentationController {
hostPopover.sourceView = super.view
let sheet = hostPopover.adaptiveSheetPresentationController
//As of 13 Beta 4 if .medium() is the only detent in landscape error occurs
sheet.detents = (isLandscape ? [.large()] : detents)
sheet.largestUndimmedDetentIdentifier =
smallestUndimmedDetentIdentifier
sheet.prefersScrollingExpandsWhenScrolledToEdge =
prefersScrollingExpandsWhenScrolledToEdge
sheet.prefersEdgeAttachedInCompactHeight =
prefersEdgeAttachedInCompactHeight
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
}
if presentedViewController == nil{
present(hostingController, animated: true, completion: nil)
}
}
/// To compensate for orientation as of 13 Beta 4 only [.large()] works for landscape
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
if UIDevice.current.orientation.isLandscape {
isLandscape = true
self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.large()]
} else {
isLandscape = false
self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = detents
}
}
}
#available(iOS 15.0, *)
struct CustomSheetView_Previews: PreviewProvider {
static var previews: some View {
CustomSheetParentView()
}
}
iOS 16 Beta
In iOS 16 Beta Apple provides a pure SwiftUI solution for a Half-Modal.
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents:(
[.medium, .large],
selection: $settingsDetent
)
}
You can also add custom detents and specify the percentages
static func custom<D>(D.Type) -> PresentationDetent
//A custom detent with a calculated height.
static func fraction(CGFloat) -> PresentationDetent
//A custom detent with the specified fractional height.
static func height(CGFloat) -> PresentationDetent
//A custom detent with the specified height.
Example:
extension PresentationDetent {
static let bar = Self.fraction(0.2)
}
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents:([.bar])
}
I've written a Swift Package that includes a custom modifier that allows you to use the half modal sheet.
Here is the link: https://github.com/AndreaMiotto/PartialSheet
Feel free to use it or to contribute
iOS 16+
It looks like half sheet is finally supported in iOS 16.
To manage the size of sheet we can use PresentationDetent and specifically presentationDetents(_:selection:)
Here's an example from the documentation:
struct ContentView: View {
#State private var showSettings = false
#State private var settingsDetent = PresentationDetent.medium
var body: some View {
Button("View Settings") {
showSettings = true
}
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents:(
[.medium, .large],
selection: $settingsDetent
)
}
}
}
Note that if you provide more that one detent, people can drag the sheet to resize it.
Here are possible values for PresentationDetent:
large
medium
fraction(CGFloat)
height(CGFloat)
custom<D>(D.Type)
You can make your own and place it inside of a zstack:
https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks
struct SlideOverCard<Content: View> : View {
#GestureState private var dragState = DragState.inactive
#State var position = CardPosition.top
var content: () -> Content
var body: some View {
let drag = DragGesture()
.updating($dragState) { drag, state, transaction in
state = .dragging(translation: drag.translation)
}
.onEnded(onDragEnded)
return Group {
Handle()
self.content()
}
.frame(height: UIScreen.main.bounds.height)
.background(Color.white)
.cornerRadius(10.0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: self.position.rawValue + self.dragState.translation.height)
.animation(self.dragState.isDragging ? nil : .spring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
.gesture(drag)
}
private func onDragEnded(drag: DragGesture.Value) {
let verticalDirection = drag.predictedEndLocation.y - drag.location.y
let cardTopEdgeLocation = self.position.rawValue + drag.translation.height
let positionAbove: CardPosition
let positionBelow: CardPosition
let closestPosition: CardPosition
if cardTopEdgeLocation <= CardPosition.middle.rawValue {
positionAbove = .top
positionBelow = .middle
} else {
positionAbove = .middle
positionBelow = .bottom
}
if (cardTopEdgeLocation - positionAbove.rawValue) < (positionBelow.rawValue - cardTopEdgeLocation) {
closestPosition = positionAbove
} else {
closestPosition = positionBelow
}
if verticalDirection > 0 {
self.position = positionBelow
} else if verticalDirection < 0 {
self.position = positionAbove
} else {
self.position = closestPosition
}
}
}
enum CardPosition: CGFloat {
case top = 100
case middle = 500
case bottom = 850
}
enum DragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
Here's my naive bottom sheet which scales to its content. Without dragging but it should be relatively easy to add if needed :)
struct BottomSheet<SheetContent: View>: ViewModifier {
#Binding var isPresented: Bool
let sheetContent: () -> SheetContent
func body(content: Content) -> some View {
ZStack {
content
if isPresented {
VStack {
Spacer()
VStack {
HStack {
Spacer()
Button(action: {
withAnimation(.easeInOut) {
self.isPresented = false
}
}) {
Text("done")
.padding(.top, 5)
}
}
sheetContent()
}
.padding()
}
.zIndex(.infinity)
.transition(.move(edge: .bottom))
.edgesIgnoringSafeArea(.bottom)
}
}
}
}
extension View {
func customBottomSheet<SheetContent: View>(
isPresented: Binding<Bool>,
sheetContent: #escaping () -> SheetContent
) -> some View {
self.modifier(BottomSheet(isPresented: isPresented, sheetContent: sheetContent))
}
}
and use like below:
.customBottomSheet(isPresented: $isPickerPresented) {
DatePicker(
"time",
selection: self.$time,
displayedComponents: .hourAndMinute
)
.labelsHidden()
}
As of Beta 2 Beta 3 you can't present a modal View as .fullScreen. It presents as .automatic -> .pageSheet. Even once that's fixed, though, I highly doubt they will give you the drag capability there for free. It would be included in the docs already.
You can use this answer to present full screen for now. Gist here.
Then, after presentation, this is a quick and dirty example of how you can recreate that interaction.
#State var drag: CGFloat = 0.0
var body: some View {
ZStack(alignment: .bottom) {
Spacer() // Use the full space
Color.red
.frame(maxHeight: 300 + self.drag) // Whatever minimum height you want, plus the drag offset
.gesture(
DragGesture(coordinateSpace: .global) // if you use .local the frame will jump around
.onChanged({ (value) in
self.drag = max(0, -value.translation.height)
})
)
}
}
I have written a SwiftUI package which includes custom iOS 13 like half modal and its buttons.
GitHub repo: https://github.com/ViktorMaric/HalfModal
I think almost every iOS developer who writes anything in SwiftUI must come up against this. I certainly did, but I thought that most of the answers here were either too complex or didn't really provide what I wanted.
I've written a very simple partial sheet which is on GitHub, available as a Swift package - HalfASheet
It probably doesn't have the bells & whistles of some of the other solutions, but it does what it needs to do. Plus, writing your own is always good for understanding what's going on.
Note - A couple of things - First of all, this is very much a work-in-progress, please feel free to improve it, etc. Secondly, I've deliberately not done a .podspec as if you're developing for SwiftUI you're on iOS 13 minimum, and the Swift Packages are so much nicer in my opinion...
Andre Carrera's answer is great and feel free to use this guide he provided: https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks
I have modified the SlideOverCard structure so it uses actual device height to measure where the card is supposed to stop (you can play with bounds.height to adjust for your needs):
struct SlideOverCard<Content: View>: View {
var bounds = UIScreen.main.bounds
#GestureState private var dragState = DragState.inactive
#State var position = UIScreen.main.bounds.height/2
var content: () -> Content
var body: some View {
let drag = DragGesture()
.updating($dragState) { drag, state, transaction in
state = .dragging(translation: drag.translation)
}
.onEnded(onDragEnded)
return Group {
Handle()
self.content()
}
.frame(height: UIScreen.main.bounds.height)
.background(Color.white)
.cornerRadius(10.0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: self.position + self.dragState.translation.height)
.animation(self.dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
.gesture(drag)
}
private func onDragEnded(drag: DragGesture.Value) {
let verticalDirection = drag.predictedEndLocation.y - drag.location.y
let cardTopEdgeLocation = self.position + drag.translation.height
let positionAbove: CGFloat
let positionBelow: CGFloat
let closestPosition: CGFloat
if cardTopEdgeLocation <= bounds.height/2 {
positionAbove = bounds.height/7
positionBelow = bounds.height/2
} else {
positionAbove = bounds.height/2
positionBelow = bounds.height - (bounds.height/9)
}
if (cardTopEdgeLocation - positionAbove) < (positionBelow - cardTopEdgeLocation) {
closestPosition = positionAbove
} else {
closestPosition = positionBelow
}
if verticalDirection > 0 {
self.position = positionBelow
} else if verticalDirection < 0 {
self.position = positionAbove
} else {
self.position = closestPosition
}
}
}
enum DragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
I was trying to do the same thing asked here, display the share sheet in a natively manner in SwiftUI without to have to implement / import a component.
I've found this solution in https://jeevatamil.medium.com/how-to-create-share-sheet-uiactivityviewcontroller-in-swiftui-cef64b26f073
struct ShareSheetView: View {
var body: some View {
Button(action: actionSheet) {
Image(systemName: "square.and.arrow.up")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
}
}
func actionSheet() {
guard let data = URL(string: "https://www.zoho.com") else { return }
let av = UIActivityViewController(activityItems: [data], applicationActivities: nil)
UIApplication.shared.windows.first?.rootViewController?.present(av, animated: true, completion: nil)
}
}
>>Update from the WWDC22
You can create half modals or small modals just using this tutorial at the minute 02:40 . It was one of the impressive way to resize the Modal without using any complex code. Just caring about the presentation.
Link video : enter link description here
Let's get from the usage :
.sheet(isPresented : yourbooleanvalue) {
//place some content inside
Text("test")
.presentationDetents([.medium,.large])
}
in this way you set a Modal that can be medium at the start and be dragged up to be large. But you can also use, .small attribute inside of this array of dimensions. I think it was the shortest path and the most use friendly. Now this method saved me life from thousand of lines of code.
In iOS 14, Swift 5, Xcode 12.5 at least, I was able to accomplish this fairly easily by simply wrapping the the UIActivityViewController in another view controller. It doesn't require inspecting the view hierarchy or using any 3rd party libraries. The only hackish part is asynchronously presenting the view controller, which might not even be necessary. Someone with more SwiftUI experience might be able to offer suggestions for improvement.
import Foundation
import SwiftUI
import UIKit
struct ActivityViewController: UIViewControllerRepresentable {
#Binding var shareURL: URL?
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> some UIViewController {
let containerViewController = UIViewController()
return containerViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
guard let shareURL = shareURL, context.coordinator.presented == false else { return }
context.coordinator.presented = true
let activityViewController = UIActivityViewController(activityItems: [shareURL], applicationActivities: nil)
activityViewController.completionWithItemsHandler = { activity, completed, returnedItems, activityError in
self.shareURL = nil
context.coordinator.presented = false
if completed {
// ...
} else {
// ...
}
}
// Executing this asynchronously might not be necessary but some of my tests
// failed because the view wasn't yet in the view hierarchy on the first pass of updateUIViewController
//
// There might be a better way to test for that condition in the guard statement and execute this
// synchronously if we can be be sure updateUIViewController is invoked at least once after the view is added
DispatchQueue.main.asyncAfter(deadline: .now()) {
uiViewController.present(activityViewController, animated: true)
}
}
class Coordinator: NSObject {
let parent: ActivityViewController
var presented: Bool = false
init(_ parent: ActivityViewController) {
self.parent = parent
}
}
}
struct ContentView: View {
#State var shareURL: URL? = nil
var body: some View {
ZStack {
Button(action: { shareURL = URL(string: "https://apple.com") }) {
Text("Share")
.foregroundColor(.white)
.padding()
}
.background(Color.blue)
if shareURL != nil {
ActivityViewController(shareURL: $shareURL)
}
}
.frame(width: 375, height: 812)
}
}
For a more generic solution, I have come up with the following idea:
https://github.com/mtzaquia/UIKitPresentationModifier
This is a generic modifier that allows you to use UIKit presentations within a SwiftUI view.
From there, the world is your oyster. The only drawback is that you may need to cascade custom environment values from the presenting view into the presented view.
myPresentingView
.presentation(isPresented: $isPresented) {
MyPresentedView()
} controllerProvider: { content in
let controller = UIHostingController(rootView: content)
if #available(iOS 15, *) {
if let sheet = controller.sheetPresentationController {
sheet.preferredCornerRadius = 12
sheet.prefersGrabberVisible = true
}
}
return controller
}
Works by me:
var body: some View {
ZStack {
YOURTOPVIEW()
VStack {
Spacer()
.frame(minWidth: .zero,
maxWidth: .infinity,
minHeight: .zero,
maxHeight: .infinity,
alignment: .top)
YOURBOTTOMVIEW()
.frame(minWidth: .zero,
maxWidth: .infinity,
minHeight: .zero,
maxHeight: .infinity,
alignment: .bottom)
}
}
}
Related
import SwiftUI
struct Level1: View {
#State var tapScore = 0
#State var showingMinedHammer = false
#State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
#State private var location = CGPoint.zero // < here !!
func mine() {
tapScore += 1
showMinedHammer()
}
func showMinedHammer() {
self.showingMinedHammer = true
DispatchQueue.main.asyncAfter(deadline: .now() + 99) {
self.showingMinedHammer = false
}
}
var body: some View {
GeometryReader { geometryProxy in
ZStack {
Image("hammer.fill").resizable().frame(width: UIScreen.main.bounds.height * 1.4, height: UIScreen.main.bounds.height)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.location = value.location // < here !!
self.mine()
})
if self.showingMinedHammer {
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
.position(self.location) // < here !!
}
}
}.edgesIgnoringSafeArea(.all)
}
}
struct Level1_Previews: PreviewProvider {
static var previews: some View {
Level1()
}
}
struct GetTapLocation:UIViewRepresentable {
var tappedCallback: ((CGPoint) -> Void)
func makeUIView(context: UIViewRepresentableContext<GetTapLocation>) -> UIView {
let v = UIView(frame: .zero)
let gesture = UITapGestureRecognizer(target: context.coordinator,
action: #selector(Coordinator.tapped))
v.addGestureRecognizer(gesture)
return v
}
class Coordinator: NSObject {
var tappedCallback: ((CGPoint) -> Void)
init(tappedCallback: #escaping ((CGPoint) -> Void)) {
self.tappedCallback = tappedCallback
}
#objc func tapped(gesture:UITapGestureRecognizer) {
let point = gesture.location(in: gesture.view)
self.tappedCallback(point)
}
}
func makeCoordinator() -> GetTapLocation.Coordinator {
return Coordinator(tappedCallback:self.tappedCallback)
}
func updateUIView(_ uiView: UIView,
context: UIViewRepresentableContext<GetTapLocation>) {
}
}
New to SwiftUI and I am trying to combine gestures that allow me to tap anywhere on the screen to add an infinite amount of "Images", but currently the image only stays on screen for a short while. Where am I going wrong? Am I supposed to combine another gesture to get the item to stay on screen whilst adding?
You only have a singular location, so only one item will ever appear in your example. To have multiple items, you'll need some sort of collection, like an Array.
The following is a pared-down example showing using an array:
struct Hammer: Identifiable {
var id = UUID()
var location: CGPoint
}
struct Level1: View {
#State var hammers: [Hammer] = [] //<-- Start with `none`
var body: some View {
ZStack {
ForEach(hammers) { hammer in // Display all of the hammers
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
.position(hammer.location)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.hammers.append(Hammer(location: value.location)) // Add a Hammer
})
.edgesIgnoringSafeArea(.all)
}
}
Note: I'm unclear on what the GeometryReader is for in your code -- you declare it, then use UIScreen dimensions -- normally in SwiftUI we just use GeometryReader
I am using my own ScrollView which has scroll start and end callbacks, so that I can perform some actions based on them, like hiding/showing a banner.
Here's my code for ScrollView
struct TrackableScrollView<Content>: View where Content: View {
private let onScrollingStarted: () -> Void
private let onScrollingFinished: () -> Void
#State var scrollViewHelper = ScrollViewHelper()
let content: Content
public init(#ViewBuilder content: () -> Content,
onScrollingStarted: #escaping () -> Void = {},
onScrollingFinished: #escaping () -> Void = {}) {
self.content = content()
self.onScrollingStarted = onScrollingStarted
self.onScrollingFinished = onScrollingFinished
}
public var body: some View {
GeometryReader { outsideProxy in
ScrollView(.vertical, showsIndicators: true) {
ZStack(alignment: .top) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
}
self.content
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
scrollViewHelper.currentOffset = value[0]
}
.simultaneousGesture(
DragGesture().onChanged { _ in
onScrollingStarted()
}
)
.onReceive(scrollViewHelper.$offsetAtScrollEnd) { _ in
onScrollingFinished()
}
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
class ScrollViewHelper: ObservableObject {
#Published var currentOffset: CGFloat = 0
#Published var offsetAtScrollEnd: CGFloat = 0
private var cancellable: AnyCancellable?
init() {
cancellable = AnyCancellable($currentOffset
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.dropFirst()
.assign(to: \.offsetAtScrollEnd, on: self))
}
}
And here's my code to show some content
struct ContentView: View {
#State var messageBannerVisisbility: Bool = false
var body: some View {
VStack {
TrackableScrollView {
VStack(alignment: .center, spacing: 0) {
ForEach(0...100, id: \.self) { i in
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.green)
.overlay(Text("\(i)"))
.padding()
}
}
} onScrollingStarted: {
hideMessageBanner()
} onScrollingFinished: {
showMessageBanner()
}
if messageBannerVisisbility {
Rectangle()
.frame(height: 100)
.foregroundColor(.red)
.overlay(Text("Random bottom view"))
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.navigationBarHidden(true)
.onAppear {
showMessageBanner()
}
}
}
I am toggling messageBannerVisisbility with animation inside showMessageBanner() function.
If I keep below code, scrolling is not smooth. Could it be because I am showing/hiding the banner with animation on scroll callbacks? I guess I can just update the banner, instead of the whole View, but I am not sure how can I achieve that!
if messageBannerVisisbility {
Rectangle()
.frame(height: 100)
.foregroundColor(.red)
.overlay(Text("Random bottom view"))
.transition(.move(edge: .bottom).combined(with: .opacity))
}
What could I do to improve scrolling experience? My app does support iOS 13, but I am also fine with implementing 2 different solutions, one for iOS 13 and other one for iOS 14 and above, if that makes life a little easier!
I have a structure, where the parent view is a hidden pop-up with content. On some user action parent View starts to slide up. I need all its content to slide up with the parent View. It was working perfectly before iOS 16, but now it's broken. If the child's View subview #State is changed during the animation, then this View appears instantly on a screen, i.e. not sliding. As I understand, because View's #State was changed SwiftUI redraws this particular View and disables its animation, so it appears in its final position without animation. I was trying to force the animation of this particular View using .animation or withAnimation. Nothing of it helped. How to fix this bug in iOS 16?
Minimal reproducible example:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import SwiftUI
struct ContentView: View {
#State var shouldShow: SlideCardPosition = .bottom
#State var text1: String = ""
var body: some View {
VStack {
Button {
shouldShow = .top
} label: {
Text("Show PopUp")
.padding(.top, 100)
}
PopUpUIView(shouldShow: $shouldShow)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
struct SlideOverCard<Content: View>: View {
#GestureState private var dragState = SlideDragState.inactive
#Binding private var position: SlideCardPosition
#State private var highlightedBackground = false
private var contentHeight: CGFloat
private var backgroundColor: Color?
private var withCorners: Bool
private var isHandleHidden: Bool
private var overlayOpacity: CGFloat
init(position: Binding<SlideCardPosition>,
contentHeight: CGFloat,
backgroundColor: Color? = nil,
withCorners: Bool = true,
isHandleHidden: Bool = false,
overlayOpacity: CGFloat = 0.75,
content: #escaping () -> Content) {
_position = position
self.content = content
self.contentHeight = contentHeight
self.backgroundColor = backgroundColor
self.withCorners = withCorners
self.isHandleHidden = isHandleHidden
self.overlayOpacity = overlayOpacity
}
var content: () -> Content
var body: some View {
return Rectangle()
.frame(width: UIScreen.screenWidth, height: UIScreen.screenHeight)
.foregroundColor(Color.black.opacity(highlightedBackground ? overlayOpacity : 0))
.position(x: UIScreen.screenWidth / 2, y: (UIScreen.screenHeight) / 2)
.edgesIgnoringSafeArea([.top, .bottom])
.overlay(
Group {
VStack(spacing: 0) {
if !isHandleHidden {
Handle()
}
self.content()
Spacer()
}
}
.frame(width: UIScreen.screenWidth, height: UIScreen.screenHeight)
.background(backgroundColor != nil ? backgroundColor! : Color.black)
.cornerRadius(withCorners ? 40.0 : 0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: position(from: position) + dragState.translation.height)
.animation(dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0), value: UUID())
.edgesIgnoringSafeArea([.top, .bottom])
.onTapGesture {}
.onChange(of: position) { _ in
withAnimation(.easeInOut) {
highlightedBackground.toggle()
}
}
)
.onTapGesture {
position = position == .bottom ? .top : .bottom
}
}
private func position(from cardPosition: SlideCardPosition) -> CGFloat {
switch cardPosition {
case .top: return UIScreen.screenHeight - contentHeight - UIScreen.topSafeAreaHeight
case .bottom: return 1000
}
}
}
enum SlideCardPosition {
case top
case bottom
}
private enum SlideDragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
private struct Handle: View {
private let handleThickness: CGFloat = 5
var body: some View {
RoundedRectangle(cornerRadius: handleThickness / 2.0)
.frame(width: 34, height: handleThickness)
.foregroundColor(.white)
.padding(.top, 8)
}
}
import UIKit
extension UIScreen {
static let screenWidth = UIScreen.main.bounds.size.width
static let screenHeight = UIScreen.main.bounds.size.height
static let screenSize = UIScreen.main.bounds.size
private static let window = UIApplication.shared.windows[0]
private static let safeFrame = window.safeAreaLayoutGuide.layoutFrame
static var topSafeAreaHeight: CGFloat {
safeFrame.minY
}
static var bottomSafeAreaHeight: CGFloat {
window.frame.maxY - safeFrame.maxY
}
}
import SwiftUI
struct PopUpUIView: View {
#Binding var shouldShow: SlideCardPosition
#State var text1 = "some random text"
var body: some View {
SlideOverCard(position: $shouldShow,
contentHeight: 300) {
VStack(spacing: 10) {
Text(text1)
.foregroundColor(.white)
.padding(.top, 80)
}
}.onChange(of: shouldShow) { _ in
if shouldShow == .top {
text1 = UUID().uuidString
}
}
}
}
struct PopUpUIView_Previews: PreviewProvider {
static var previews: some View {
PopUpUIView(shouldShow: .constant(.bottom))
}
}
Example of incorrect animation with a dynamic text.
Example of what I want to achieve. It is working fine if text is static.
I can’t figure out how to change the color of the image icon after clicking on it, for example, if the window is active, then the color of the icon is blue, if not, then it’s gray.
here is an example of what i am asking
If you know the solution please help me
Here is the full code
This code is fully working, you can check it out
import SwiftUI
struct AllLinesView: View {
#State var currentSelection: Int = 0
var body: some View {
PagerTabView(tint: .white, selection: $currentSelection) {
Image(systemName: "bolt.fill")
.pageLabel()
Image(systemName: "flame")
.pageLabel()
Image(systemName: "person.fill")
.pageLabel()
} content: {
Color.red
.pageView(ignoresSafeArea: true, edges: .bottom)
Color.green
.pageView(ignoresSafeArea: true, edges: .bottom)
Color.yellow
.pageView(ignoresSafeArea: true, edges: .bottom)
}
.ignoresSafeArea(.container, edges: .bottom)
}
}
TabView
struct PagerTabView<Content: View, Label: View>: View {
var content: Content
var label: Label
var tint: Color
#Binding var selection: Int
init(tint:Color,selection: Binding<Int>,#ViewBuilder labels: #escaping ()->Label,#ViewBuilder content: #escaping ()->Content) {
self.content = content()
self.label = labels()
self.tint = tint
self._selection = selection
}
#State var offset: CGFloat = 0
#State var maxTabs: CGFloat = 0
#State var tabOffset: CGFloat = 0
var body: some View {
VStack(alignment: .leading,spacing: 0) {
HStack(spacing: 0) {
label
}
.overlay(
HStack(spacing: 0) {
ForEach(0..<Int(maxTabs), id: \.self) { index in
Rectangle()
.fill(Color.black.opacity(0.01))
.onTapGesture {
let newOffset = CGFloat(index) * getScreenBounds().width
self.offset = newOffset
}
}
}
)
.foregroundColor(tint)
Capsule()
.fill(tint)
.frame(width: maxTabs == 0 ? 0 : (getScreenBounds().width / maxTabs), height: 2)
.padding(.top, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.offset(x: tabOffset)
OffsetPageTabView(selection: $selection,offset: $offset) {
HStack(spacing: 0) {
content
}
.overlay(
GeometryReader { proxy in
Color.clear
.preference(key: TabPreferenceKey.self, value: proxy.frame(in: .global))
}
)
.onPreferenceChange(TabPreferenceKey.self) { proxy in
let minX = -proxy.minX
let maxWidth = proxy.width
let screenWidth = getScreenBounds().width
let maxTabs = (maxWidth / screenWidth).rounded()
let progress = minX / screenWidth
let tabOffset = progress * (screenWidth / maxTabs)
self.tabOffset = tabOffset
self.maxTabs = maxTabs
}
}
}
}
}
TabPreferenceKey
struct TabPreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .init()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
pageLabel - pageView
extension View {
//IMAGE
func pageLabel()->some View {
self
.frame(maxWidth: .infinity, alignment: .center)
}
//PAGE
func pageView(ignoresSafeArea: Bool = false, edges: Edge.Set = [])->some View {
self
.frame(width: getScreenBounds().width, alignment: .center)
.ignoresSafeArea(ignoresSafeArea ? .container : .init(), edges: edges)
}
func getScreenBounds()->CGRect {
return UIScreen.main.bounds
}
}
OffsetPage
struct OffsetPageTabView<Content: View>: UIViewRepresentable {
var content: Content
#Binding var offset: CGFloat
#Binding var selection: Int
func makeCoordinator() -> Coordinator {
return OffsetPageTabView.Coordinator(parent: self)
}
init(selection: Binding<Int>,offset: Binding<CGFloat>, #ViewBuilder content: #escaping ()->Content) {
self.content = content()
self._offset = offset
self._selection = selection
}
func makeUIView(context: Context) -> UIScrollView {
let scrollview = UIScrollView()
let hostview = UIHostingController(rootView: content)
hostview.view.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostview.view.topAnchor.constraint(equalTo: scrollview.topAnchor),
hostview.view.leadingAnchor.constraint(equalTo: scrollview.leadingAnchor),
hostview.view.trailingAnchor.constraint(equalTo: scrollview.trailingAnchor),
hostview.view.bottomAnchor.constraint(equalTo: scrollview.bottomAnchor),
hostview.view.heightAnchor.constraint(equalTo: scrollview.heightAnchor)
]
scrollview.addSubview(hostview.view)
scrollview.addConstraints(constraints)
scrollview.isPagingEnabled = true
scrollview.showsVerticalScrollIndicator = false
scrollview.showsHorizontalScrollIndicator = false
scrollview.delegate = context.coordinator
return scrollview
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
let currentOffset = uiView.contentOffset.x
if currentOffset != offset {
print("updating")
uiView.setContentOffset(CGPoint(x: offset, y: 0), animated: true)
}
}
class Coordinator: NSObject, UIScrollViewDelegate {
var parent: OffsetPageTabView
init(parent: OffsetPageTabView) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.x
let maxSize = scrollView.contentSize.width
let currentSelection = (offset / maxSize).rounded()
parent.selection = Int(currentSelection)
parent.offset = offset
}
}
}
This code is not built in a way that is easily changeable. The primary issue is that it uses ViewBuilders for the labels and pages, but our (not Apple's) SwiftUI code doesn't have insight into how many elements get passed into a ViewBuilder like this. So, I had to add a somewhat ugly hack of passing the number of child views by hand. I also had to add foregroundColor modifiers explicitly for each label, which is another result of shortcomings of the way the existing code works.
The original code's currentSelection logic was completely broken (ie didn't function at all), but was easily fixable once explicitly passing the number of child elements.
See updated code including inline comments of where changes were made.
struct AllLinesView: View {
#State var currentSelection: Int = 0
var body: some View {
PagerTabView(tint: .white, selection: $currentSelection, children: 3) { //<-- Here
Image(systemName: "bolt.fill")
.pageLabel()
.foregroundColor(currentSelection == 0 ? .blue : .white) //<-- Here
Image(systemName: "flame")
.pageLabel()
.foregroundColor(currentSelection == 1 ? .blue : .white) //<-- Here
Image(systemName: "person.fill")
.pageLabel()
.foregroundColor(currentSelection == 2 ? .blue : .white) //<-- Here
} content: {
Color.red
.pageView(ignoresSafeArea: true, edges: .bottom)
Color.green
.pageView(ignoresSafeArea: true, edges: .bottom)
Color.yellow
.pageView(ignoresSafeArea: true, edges: .bottom)
}
.ignoresSafeArea(.container, edges: .bottom)
.onChange(of: currentSelection) { newValue in
print(newValue)
}
}
}
struct PagerTabView<Content: View, Label: View>: View {
var content: Content
var label: Label
var tint: Color
var children: Int //<-- Here
#Binding var selection: Int
init(tint:Color,selection: Binding<Int>,children: Int, #ViewBuilder labels: #escaping ()->Label,#ViewBuilder content: #escaping ()->Content) {
self.children = children
self.content = content()
self.label = labels()
self.tint = tint
self._selection = selection
}
#State var offset: CGFloat = 0
#State var maxTabs: CGFloat = 0
#State var tabOffset: CGFloat = 0
var body: some View {
VStack(alignment: .leading,spacing: 0) {
HStack(spacing: 0) {
label
}
.overlay(
HStack(spacing: 0) {
ForEach(0..<Int(maxTabs), id: \.self) { index in
Rectangle()
.fill(Color.black.opacity(0.01))
.onTapGesture {
let newOffset = CGFloat(index) * getScreenBounds().width
self.offset = newOffset
}
}
}
)
.foregroundColor(tint)
Capsule()
.fill(tint)
.frame(width: maxTabs == 0 ? 0 : (getScreenBounds().width / maxTabs), height: 2)
.padding(.top, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.offset(x: tabOffset)
OffsetPageTabView(selection: $selection,offset: $offset, children: children) { //<-- Here
HStack(spacing: 0) {
content
}
.overlay(
GeometryReader { proxy in
Color.clear
.preference(key: TabPreferenceKey.self, value: proxy.frame(in: .global))
}
)
.onPreferenceChange(TabPreferenceKey.self) { proxy in
let minX = -proxy.minX
let maxWidth = proxy.width
let screenWidth = getScreenBounds().width
let maxTabs = (maxWidth / screenWidth).rounded()
let progress = minX / screenWidth
let tabOffset = progress * (screenWidth / maxTabs)
self.tabOffset = tabOffset
self.maxTabs = maxTabs
}
}
}
}
}
struct TabPreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .init()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
extension View {
//IMAGE
func pageLabel()->some View {
self
.frame(maxWidth: .infinity, alignment: .center)
}
//PAGE
func pageView(ignoresSafeArea: Bool = false, edges: Edge.Set = [])->some View {
self
.frame(width: getScreenBounds().width, alignment: .center)
.ignoresSafeArea(ignoresSafeArea ? .container : .init(), edges: edges)
}
func getScreenBounds()->CGRect {
return UIScreen.main.bounds
}
}
struct OffsetPageTabView<Content: View>: UIViewRepresentable {
var content: Content
#Binding var offset: CGFloat
#Binding var selection: Int
var children: Int //<-- Here
func makeCoordinator() -> Coordinator {
return OffsetPageTabView.Coordinator(parent: self)
}
init(selection: Binding<Int>,offset: Binding<CGFloat>, children: Int, #ViewBuilder content: #escaping ()->Content) {
self.content = content()
self._offset = offset
self._selection = selection
self.children = children
}
func makeUIView(context: Context) -> UIScrollView {
let scrollview = UIScrollView()
let hostview = UIHostingController(rootView: content)
hostview.view.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostview.view.topAnchor.constraint(equalTo: scrollview.topAnchor),
hostview.view.leadingAnchor.constraint(equalTo: scrollview.leadingAnchor),
hostview.view.trailingAnchor.constraint(equalTo: scrollview.trailingAnchor),
hostview.view.bottomAnchor.constraint(equalTo: scrollview.bottomAnchor),
hostview.view.heightAnchor.constraint(equalTo: scrollview.heightAnchor)
]
scrollview.addSubview(hostview.view)
scrollview.addConstraints(constraints)
scrollview.isPagingEnabled = true
scrollview.showsVerticalScrollIndicator = false
scrollview.showsHorizontalScrollIndicator = false
scrollview.delegate = context.coordinator
return scrollview
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
let currentOffset = uiView.contentOffset.x
if currentOffset != offset {
//print("updating")
uiView.setContentOffset(CGPoint(x: offset, y: 0), animated: true)
}
}
class Coordinator: NSObject, UIScrollViewDelegate {
var parent: OffsetPageTabView
init(parent: OffsetPageTabView) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.x
let maxSize = scrollView.contentSize.width
let currentSelection = (offset / maxSize) * CGFloat(parent.children) //<-- Here
print("max: ", maxSize, offset)
print("Set selection to: ", Int(currentSelection), currentSelection)
parent.selection = Int(currentSelection)
parent.offset = offset
}
}
}
I need implement next flow in my code:
Show text(1 line) and if it is cut or clipped (not fully visible) I should remove them,
I don't want scale text or make it in 2 lines
so I found solution, this solution is compose from few different topics:
struct TruncatableText: View {
let text: (() -> Text)
let lineLimit: Int?
#State private var intrinsicSize: CGSize = .zero
#State private var truncatedSize: CGSize = .zero
#State private var hide: Bool = false
var body: some View {
text()
.lineLimit(lineLimit)
.readSize { size in
truncatedSize = size
hide = truncatedSize != intrinsicSize
}
.background(
text()
.fixedSize(horizontal: false, vertical: true)
.hidden()
.readSize { size in
intrinsicSize = size
hide = truncatedSize != intrinsicSize
}
)
.isShow(!hide)
}
}
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
#ViewBuilder func isShow(_ show: Bool) -> some View {
if show {
self
} else {
self.hidden()
}
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
and using it:
TruncatableText(
text: {
Text("title")
.font(.system(size: 21, weight: .semibold))
.foregroundColor(Color.blue)
},
lineLimit: 1
)