UIScrollView in SwiftUI does not line up with full screen - swiftui

I am trying to make a vertical scroll view similar to Tiktok and have made a custom scroll view using UIScrollView. I am having a problem getting the scroll view to align with the screen. It seems to be something with how the scroll view is interacting with the safe area.
The full code is shown below.
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
GeometryReader { geo in
CustomScrollView(screenWidth: geo.frame(in: .local).width, screenHeight: geo.frame(in: .local).height)
}
Rectangle()
.frame(height: 75)
}
.ignoresSafeArea()
}
}
struct ScrollContents: View {
var screenWidth: CGFloat
var screenHeight: CGFloat
var body: some View {
VStack(spacing: 0) {
Rectangle()
.foregroundColor(.yellow)
.frame(width: screenWidth, height: screenHeight)
Rectangle()
.foregroundColor(.green)
.frame(width: screenWidth, height: screenHeight)
Rectangle()
.foregroundColor(.red)
.frame(width: screenWidth, height: screenHeight)
}
}
}
struct CustomScrollView : UIViewRepresentable {
var screenWidth: CGFloat
var screenHeight: CGFloat
func makeCoordinator() -> Coordinator {
return CustomScrollView.Coordinator(parent: self)
}
func makeUIView(context: Context) -> UIScrollView{
let view = UIScrollView()
let childView = UIHostingController(rootView: ScrollContents(screenWidth: screenWidth, screenHeight: screenHeight))
view.insetsLayoutMarginsFromSafeArea = true
childView.view.insetsLayoutMarginsFromSafeArea = true
childView.view.frame = CGRect(x: 0, y: screenHeight, width: screenWidth, height: screenHeight)
view.contentSize = CGSize(width: screenWidth, height: screenHeight*3)
view.addSubview(childView.view)
view.showsVerticalScrollIndicator = false
view.showsHorizontalScrollIndicator = false
view.contentInsetAdjustmentBehavior = .never
view.isPagingEnabled = true
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
}
class Coordinator : NSObject,UIScrollViewDelegate{
var parent : CustomScrollView
init(parent : CustomScrollView) {
self.parent = parent
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
}
}
}
When the app opens, the view lines up perfectly, but when I scroll down there is a slight offset, as shown below.
view.contentInsetAdjustmentBehavior = .never
If I change the UIScrollView's contentInsetAdjustmentBehavior to "always" on line 68, the scroll effect works perfectly, but this adds an extra paging effect for the safe area at the top, as shown below.
view.contentInsetAdjustmentBehavior = .always
Does anyone know how I can get this effect?

Related

Can't use UIViewRepresentable based on UIView more than once in the same View

When I try to place several UIViewRepresentable views to ContentView, it's shown only the last one. Here is the code:
struct ContentView: View {
#State var customView = UIView()
var body: some View {
NavigationView {
VStack {
CustomView(view: customView)
CustomView(view: customView)
.offset(x: 100, y: 0)
}
.navigationTitle("Test UIViewRepresentable")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
makeNewView()
} label: {
Image(systemName: "plus")
}
}
}
}
}
private func makeNewView() {
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 50, height: 50))
let layer = CAShapeLayer()
layer.path = path.cgPath
layer.fillColor = CGColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
customView.layer.addSublayer(layer)
}
}
I removed model and viewmodel here to simplify the code.
And here is the UIView representable:
struct CustomView: UIViewRepresentable {
var view: UIView
func makeUIView(context: Context) -> UIView {
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
The second view is shifted down, so the first one is in its place but not shown. What should I do to get both views shown in ContentView?
Instead of creating #State var customView = UIView() in ContentView create this in CustomView and use in ContentView Multiple Times like this
UIViewRepresentable
struct CustomView: UIViewRepresentable {
var view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
func makeUIView(context: Context) -> UIView {
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100))
let layer = CAShapeLayer()
layer.path = path.cgPath
layer.fillColor = CGColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
view.layer.addSublayer(layer)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
SwiftUI View
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
CustomView()
CustomView()
.offset(x: 100, y: 0)
}
.navigationTitle("Test UIViewRepresentable")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
// CustomView()
} label: {
Image(systemName: "plus")
}
}
}
}
}
}

How to save a finished image from a SwiftUI 2.0 scroll view after it's been resized and repositioned by the user

Using Swift 2.0 I am hoping to find a way to capture the resized image after the user has selected how they want to see it in the frame from the scroll view (ZoomScrollView).
I know there are complex examples out there from Swift but was hoping to find a simpler way to capture this in Swift 2.0. In all my searching I've heard references to using ZStack and some masks or overlays but can't find a simple good example.
I am hoping someone can update my example with the ZStack, masks, etc and how to extract the image for saving or provide a better example.
import SwiftUI
struct ContentView: View {
#Environment(\.presentationMode) var presentationMode
#State var isAccepted: Bool = false
#State var isShowingImagePicker = false
#State var isShowingActionPicker = false
#State var sourceType:UIImagePickerController.SourceType = .camera
#State var image:UIImage?
var body: some View {
HStack {
Color(UIColor.systemYellow).frame(width: 8)
VStack(alignment: .leading) {
HStack {
Spacer()
VStack {
if image != nil {
ZoomScrollView {
Image(uiImage: image!)
.resizable()
.scaledToFit()
}
.frame(width: 300, height: 300, alignment: .center)
.clipShape(Circle())
} else {
Image(systemName: "person.crop.circle")
.resizable()
.font(.system(size: 32, weight: .light))
.frame(width: 300, height: 300, alignment: .center)
.cornerRadius(180)
.foregroundColor(Color(.systemGray))
.clipShape(Circle())
}
}
Spacer()
}
Spacer()
HStack {
Button(action: {
self.isShowingActionPicker = true
}, label: {
Text("Select Image")
.foregroundColor(.blue)
})
.frame(width: 130)
.actionSheet(isPresented: $isShowingActionPicker, content: {
ActionSheet(title: Text("Select a profile avatar picture"), message: nil, buttons: [
.default(Text("Camera"), action: {
self.isShowingImagePicker = true
self.sourceType = .camera
}),
.default(Text("Photo Library"), action: {
self.isShowingImagePicker = true
self.sourceType = .photoLibrary
}),
.cancel()
])
})
.sheet(isPresented: $isShowingImagePicker) {
imagePicker(image: $image, isShowingImagePicker: $isShowingImagePicker ,sourceType: self.sourceType)
}
Spacer()
// Save button
Button(action: {
// Save Image here... print for now just see if file dimensions are the right size
print("saved: ", image!)
self.presentationMode.wrappedValue.dismiss()
}
) {
HStack {
Text("Save").foregroundColor(isAccepted ? .gray : .blue)
}
}
.frame(width: 102)
.padding(.top)
.padding(.bottom)
//.buttonStyle(RoundedCorners())
.disabled(isAccepted) // Disable if if already isAccepted is true
}
}
Spacer()
Color(UIColor.systemYellow).frame(width: 8)
}
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top)
.background(Color(UIColor.systemYellow))
}
}
struct ZoomScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
// create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
}
}
struct imagePicker:UIViewControllerRepresentable {
#Binding var image: UIImage?
#Binding var isShowingImagePicker: Bool
typealias UIViewControllerType = UIImagePickerController
typealias Coordinator = imagePickerCoordinator
var sourceType:UIImagePickerController.SourceType = .camera
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = sourceType
picker.delegate = context.coordinator
return picker
}
func makeCoordinator() -> imagePickerCoordinator {
return imagePickerCoordinator(image: $image, isShowingImagePicker: $isShowingImagePicker)
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
}
class imagePickerCoordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
#Binding var image: UIImage?
#Binding var isShowingImagePicker: Bool
init(image:Binding<UIImage?>, isShowingImagePicker: Binding<Bool>) {
_image = image
_isShowingImagePicker = isShowingImagePicker
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let uiimage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
image = uiimage
isShowingImagePicker = false
}
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
isShowingImagePicker = false
}
}
Just want to return the image that's zoomed in the circle. The image can be square (re: the 300x300 frame), that's fine just need the zoomed image not whole screen or the original image.
the following changes were successful based the comments:
Add the following State variables:
#State private var rect: CGRect = .zero
#State private var uiimage: UIImage? = nil // resized image
Added "RectGetter" to the picked image frame after image selected selected
if image != nil {
ZoomScrollView {
Image(uiImage: image!)
.resizable()
.scaledToFit()
}
.frame(width: 300, height: 300, alignment: .center)
.clipShape(Circle())
.background(RectGetter(rect: $rect))
Here is the struct and extension I added
extension UIView {
func asImage(rect: CGRect) -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: rect)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
struct RectGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { proxy in
self.createView(proxy: proxy)
}
}
func createView(proxy: GeometryProxy) -> some View {
DispatchQueue.main.async {
self.rect = proxy.frame(in: .global)
}
return Rectangle().fill(Color.clear)
}
}
Last I set the image to save
self.uiimage = UIApplication.shared.windows[0].rootViewController?.view.asImage(rect: self.rect)
This assumes the root controller. However, in my production app I had to point to self
self.uiimage = UIApplication.shared.windows[0].self.asImage(rect: self.rect)
Then I was able to save that image.
A couple of notes. The image returned is the rectangle which is fine. However due to the way the image is captured the rest of the rectangle outside the cropShape of a circle has the background color. In this case yellow at the for corners outside the circle. There is probably a way to have some sort of ZOrder mask that overlays the image for display when you are resizing the image but then this accesses the right layer and saves the full rectangle picture. If anyone wants to suggest further that would be a cleaner solution but this works assuming you will always display the picture in the same crop shape it was saved in.

Recreating a masked blur effect in SwiftUI

I've created this masked blur effect (code below), it runs in SwiftUI, but uses UIViewRepresentable for the masking, is it possible to re-create the same effect, but just in pure SwiftUI?
Here's the current code, if you run it, use your finger to drag on the screen, this moves the mask to reveal underneath.
import SwiftUI
import UIKit
struct TestView: View {
#State var position: CGPoint = .zero
var simpleDrag: some Gesture {
DragGesture()
.onChanged { value in
self.position = value.location
}
}
var body: some View {
ZStack {
Circle()
.fill(Color.green)
.frame(height: 200)
Circle()
.fill(Color.pink)
.frame(height: 200)
.offset(x: 50, y: 100)
Circle()
.fill(Color.orange)
.frame(height: 100)
.offset(x: -50, y: 00)
BlurView(style: .light, position: $position)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.gesture(
simpleDrag
)
}
}
struct BlurView: UIViewRepresentable {
let style: UIBlurEffect.Style
#Binding var position: CGPoint
func makeUIView(context: UIViewRepresentableContext<BlurView>) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
let blurEffect = UIBlurEffect(style: style)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.translatesAutoresizingMaskIntoConstraints = false
view.insertSubview(blurView, at: 0)
NSLayoutConstraint.activate([
blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
blurView.widthAnchor.constraint(equalTo: view.widthAnchor),
])
let clipPath = UIBezierPath(rect: UIScreen.main.bounds)
let circlePath = UIBezierPath(ovalIn: CGRect(x: 100, y: 0, width: 200, height: 200))
clipPath.append(circlePath)
let layer = CAShapeLayer()
layer.path = clipPath.cgPath
layer.fillRule = .evenOdd
view.layer.mask = layer
view.layer.masksToBounds = true
return view
}
func updateUIView(_ uiView: UIView,
context: UIViewRepresentableContext<BlurView>) {
let clipPath = UIBezierPath(rect: UIScreen.main.bounds)
let circlePath = UIBezierPath(ovalIn: CGRect(x: position.x, y: position.y, width: 200, height: 200))
clipPath.append(circlePath)
let layer = CAShapeLayer()
layer.path = clipPath.cgPath
layer.fillRule = .evenOdd
uiView.layer.mask = layer
uiView.layer.masksToBounds = true
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
I think I nearly have a solution, I can use a viewmodifer to render the result twice on top of each other with a ZStack, I can blur one view, and use a mask to knock a hole in it.
import SwiftUI
struct TestView2: View {
#State var position: CGPoint = .zero
var simpleDrag: some Gesture {
DragGesture()
.onChanged { value in
self.position = value.location
}
}
var body: some View {
ZStack {
Circle()
.fill(Color.green)
.frame(height: 200)
Circle()
.fill(Color.pink)
.frame(height: 200)
.offset(x: 50, y: 100)
Circle()
.fill(Color.orange)
.frame(height: 100)
.offset(x: -50, y: 00)
}
.maskedBlur(position: $position)
.gesture(
simpleDrag
)
}
}
struct MaskedBlur: ViewModifier {
#Binding var position: CGPoint
/// Render the content twice
func body(content: Content) -> some View {
ZStack {
content
content
.blur(radius: 10)
.mask(
Hole(position: $position)
.fill(style: FillStyle(eoFill: true))
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
}
extension View {
func maskedBlur(position: Binding<CGPoint>) -> some View {
self.modifier(MaskedBlur(position: position))
}
}
struct Hole: Shape {
#Binding var position: CGPoint
func path(in rect: CGRect) -> Path {
var path = Path()
path.addRect(UIScreen.main.bounds)
path.addEllipse(in: CGRect(x: position.x, y: position.y, width: 200, height: 200))
return path
}
}
#if DEBUG
struct TestView2_Previews: PreviewProvider {
static var previews: some View {
TestView2()
}
}
#endif

SwiftUI onTapGesture on Color.clear background behaves differently to Color.blue

I am making a custom Picker in the SegmentedPickerStyle(). I want to have the same behaviour but when I tap on the area between the content and the border of one of the possible selections the onTapGesture does not work. When I add a blue background it does work but with a clear background it doesn't.
Working with blue background
Not working with clear background
Not working code:
import SwiftUI
struct PickerElementView<Content>: View where Content : View {
#Binding var selectedElement: Int
let content: () -> Content
#inlinable init(_ selectedElement: Binding<Int>, #ViewBuilder content: #escaping () -> Content) {
self._selectedElement = selectedElement
self.content = content
}
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.background(Color.clear)
// ##################################################################
.border(Color.yellow, width: 5)
}
}
}
struct PickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
var elements: [(id: Int, view: AnyView)]
#Binding var selectedElement: Int
#State var internalSelectedElement: Int = 0
private var width: CGFloat = 220
private var height: CGFloat = 100
private var cornerRadius: CGFloat = 20
private var factor: CGFloat = 0.95
private var color = Color(UIColor.systemGray)
private var selectedColor = Color(UIColor.systemGray2)
init(_ selectedElement: Binding<Int>) {
self._selectedElement = selectedElement
self.elements = [
(id: 0, view: AnyView(PickerElementView(selectedElement) {
Text("9").font(.system(.title))
})),
(id: 1, view: AnyView(PickerElementView(selectedElement) {
Text("5").font(.system(.title))
})),
]
self.internalSelectedElement = selectedElement.wrappedValue
}
func calcXPosition() -> CGFloat {
var pos = CGFloat(-self.width * self.factor / 4)
pos += CGFloat(self.internalSelectedElement) * self.width * self.factor / 2
return pos
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(self.selectedColor)
.cornerRadius(self.cornerRadius * self.factor)
.frame(width: self.width * self.factor / CGFloat(self.elements.count), height: self.height - self.width * (1 - self.factor))
.offset(x: calcXPosition())
.animation(.easeInOut(duration: 0.2))
HStack {
ForEach(self.elements, id: \.id) { item in
item.view
.gesture(TapGesture().onEnded { _ in
print(item.id)
self.selectedElement = item.id
withAnimation {
self.internalSelectedElement = item.id
}
})
}
}
}
.frame(width: self.width, height: self.height)
.background(self.color)
.cornerRadius(self.cornerRadius)
.padding()
}
}
struct PickerView_Previews: PreviewProvider {
static var previews: some View {
PickerView(.constant(1))
}
}
Change the color where I marked it.
Does anyone know why they behave differently and how I can fix this?
The one line answer is instead of setting backgroundColor, please set contentShape for hit testing.
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.contentShape(Rectangle())
// ##################################################################
.border(Color.yellow, width: 5)
}
}
Transparent views are not tappable by default in SwiftUI because their content shape is zero.
You can change this behavior by using .contentShape modifier:
Color.clear
.frame(width: 300, height: 300)
.contentShape(Rectangle())
.onTapGesture { print("tapped") }
It appears to be a design decision that any Color with an opacity of 0 is untappable.
Color.clear.onTapGesture { print("tapped") } // will not print
Color.blue.opacity(0).onTapGesture { print("tapped") } // will not print
Color.blue.onTapGesture { print("tapped") } // will print
Color.blue.opacity(0.0001).onTapGesture { print("tapped") } // will print
You can use the 4th option to get around this, as it is visually indistinguishable from the 1st.
I was struggling a similar problem to get the tap on a RoundedRectangle.
My simple solution was to set the opacity to a very low value and it worked
RoundedRectangle(cornerRadius: 12)
.fill(Color.black)
.opacity(0.0001)
.frame(width: 32, height: 32)
.onTapGesture {
...
}

Activity indicator in SwiftUI

Trying to add a full screen activity indicator in SwiftUI.
I can use .overlay(overlay: ) function in View Protocol.
With this, I can make any view overlay, but I can't find the iOS default style UIActivityIndicatorView equivalent in SwiftUI.
How can I make a default style spinner with SwiftUI?
NOTE: This is not about adding activity indicator in UIKit framework.
As of Xcode 12 beta (iOS 14), a new view called ProgressView is available to developers, and that can display both determinate and indeterminate progress.
Its style defaults to CircularProgressViewStyle, which is exactly what we're looking for.
var body: some View {
VStack {
ProgressView()
// and if you want to be explicit / future-proof...
// .progressViewStyle(CircularProgressViewStyle())
}
}
Xcode 11.x
Quite a few views are not yet represented in SwiftUI, but it's easily to port them into the system.
You need to wrap UIActivityIndicator and make it UIViewRepresentable.
(More about this can be found in the excellent WWDC 2019 talk - Integrating SwiftUI)
struct ActivityIndicator: UIViewRepresentable {
#Binding var isAnimating: Bool
let style: UIActivityIndicatorView.Style
func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
return UIActivityIndicatorView(style: style)
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
}
}
Then you can use it as follows - here's an example of a loading overlay.
Note: I prefer using ZStack, rather than overlay(:_), so I know exactly what's going on in my implementation.
struct LoadingView<Content>: View where Content: View {
#Binding var isShowing: Bool
var content: () -> Content
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .center) {
self.content()
.disabled(self.isShowing)
.blur(radius: self.isShowing ? 3 : 0)
VStack {
Text("Loading...")
ActivityIndicator(isAnimating: .constant(true), style: .large)
}
.frame(width: geometry.size.width / 2,
height: geometry.size.height / 5)
.background(Color.secondary.colorInvert())
.foregroundColor(Color.primary)
.cornerRadius(20)
.opacity(self.isShowing ? 1 : 0)
}
}
}
}
To test it, you can use this example code:
struct ContentView: View {
var body: some View {
LoadingView(isShowing: .constant(true)) {
NavigationView {
List(["1", "2", "3", "4", "5"], id: \.self) { row in
Text(row)
}.navigationBarTitle(Text("A List"), displayMode: .large)
}
}
}
}
Result:
iOS 14
it's just a simple view.
ProgressView()
Currently, it's defaulted to CircularProgressViewStyle but you can manually set the style of it by adding the following modifer:
.progressViewStyle(CircularProgressViewStyle())
Also, the style could be anything that conforms to ProgressViewStyle
iOS 13 and above
Fully customizable Standard UIActivityIndicator in SwiftUI: (Exactly as a native View):
You can build and configure it (as much as you could in the original UIKit):
ActivityIndicator(isAnimating: loading)
.configure { $0.color = .yellow } // Optional configurations (🎁 bouns)
.background(Color.blue)
Just implement this base struct and you will be good to go:
struct ActivityIndicator: UIViewRepresentable {
typealias UIView = UIActivityIndicatorView
var isAnimating: Bool
fileprivate var configuration = { (indicator: UIView) in }
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView { UIView() }
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<Self>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
configuration(uiView)
}
}
🎁 Bouns Extension:
With this little helpful extension, you can access the configuration through a modifier like other SwiftUI views:
extension View where Self == ActivityIndicator {
func configure(_ configuration: #escaping (Self.UIView)->Void) -> Self {
Self.init(isAnimating: self.isAnimating, configuration: configuration)
}
}
The classic way:
Also you can configure the view in a classic initializer:
ActivityIndicator(isAnimating: loading) {
$0.color = .red
$0.hidesWhenStopped = false
//Any other UIActivityIndicatorView property you like
}
This method is fully adaptable. For example, you can see How to make TextField become the first responder with the same method here
If you want to a swift-ui-style solution, then this is the magic:
import Foundation
import SwiftUI
struct ActivityIndicator: View {
#State private var isAnimating: Bool = false
var body: some View {
GeometryReader { (geometry: GeometryProxy) in
ForEach(0..<5) { index in
Group {
Circle()
.frame(width: geometry.size.width / 5, height: geometry.size.height / 5)
.scaleEffect(calcScale(index: index))
.offset(y: calcYOffset(geometry))
}.frame(width: geometry.size.width, height: geometry.size.height)
.rotationEffect(!self.isAnimating ? .degrees(0) : .degrees(360))
.animation(Animation
.timingCurve(0.5, 0.15 + Double(index) / 5, 0.25, 1, duration: 1.5)
.repeatForever(autoreverses: false))
}
}
.aspectRatio(1, contentMode: .fit)
.onAppear {
self.isAnimating = true
}
}
func calcScale(index: Int) -> CGFloat {
return (!isAnimating ? 1 - CGFloat(Float(index)) / 5 : 0.2 + CGFloat(index) / 5)
}
func calcYOffset(_ geometry: GeometryProxy) -> CGFloat {
return geometry.size.width / 10 - geometry.size.height / 2
}
}
Simply to use:
ActivityIndicator()
.frame(width: 50, height: 50)
Hope it helps!
Example Usage:
ActivityIndicator()
.frame(width: 200, height: 200)
.foregroundColor(.orange)
Custom Indicators
Although Apple supports native Activity Indicator now from the SwiftUI 2.0, You can Simply implement your own animations. These are all supported on SwiftUI 1.0. Also it is working in widgets.
Arcs
struct Arcs: View {
#Binding var isAnimating: Bool
let count: UInt
let width: CGFloat
let spacing: CGFloat
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
.rotationEffect(isAnimating ? .degrees(360) : .degrees(0))
.animation(
Animation.default
.speed(Double.random(in: 0.2...0.5))
.repeatCount(isAnimating ? .max : 1, autoreverses: false)
)
}
}
.aspectRatio(contentMode: .fit)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
Group { () -> Path in
var p = Path()
p.addArc(center: CGPoint(x: geometrySize.width/2, y: geometrySize.height/2),
radius: geometrySize.width/2 - width/2 - CGFloat(index) * (width + spacing),
startAngle: .degrees(0),
endAngle: .degrees(Double(Int.random(in: 120...300))),
clockwise: true)
return p.strokedPath(.init(lineWidth: width))
}
.frame(width: geometrySize.width, height: geometrySize.height)
}
}
Demo of different variations
Bars
struct Bars: View {
#Binding var isAnimating: Bool
let count: UInt
let spacing: CGFloat
let cornerRadius: CGFloat
let scaleRange: ClosedRange<Double>
let opacityRange: ClosedRange<Double>
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
}
}
.aspectRatio(contentMode: .fit)
}
private var scale: CGFloat { CGFloat(isAnimating ? scaleRange.lowerBound : scaleRange.upperBound) }
private var opacity: Double { isAnimating ? opacityRange.lowerBound : opacityRange.upperBound }
private func size(count: UInt, geometry: CGSize) -> CGFloat {
(geometry.width/CGFloat(count)) - (spacing-2)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.frame(width: size(count: count, geometry: geometrySize), height: geometrySize.height)
.scaleEffect(x: 1, y: scale, anchor: .center)
.opacity(opacity)
.animation(
Animation
.default
.repeatCount(isAnimating ? .max : 1, autoreverses: true)
.delay(Double(index) / Double(count) / 2)
)
.offset(x: CGFloat(index) * (size(count: count, geometry: geometrySize) + spacing))
}
}
Demo of different variations
Blinkers
struct Blinking: View {
#Binding var isAnimating: Bool
let count: UInt
let size: CGFloat
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
.aspectRatio(contentMode: .fit)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
let angle = 2 * CGFloat.pi / CGFloat(count) * CGFloat(index)
let x = (geometrySize.width/2 - size/2) * cos(angle)
let y = (geometrySize.height/2 - size/2) * sin(angle)
return Circle()
.frame(width: size, height: size)
.scaleEffect(isAnimating ? 0.5 : 1)
.opacity(isAnimating ? 0.25 : 1)
.animation(
Animation
.default
.repeatCount(isAnimating ? .max : 1, autoreverses: true)
.delay(Double(index) / Double(count) / 2)
)
.offset(x: x, y: y)
}
}
Demo of different variations
For the sake of preventing walls of code, you can find more elegant indicators in this repo hosted on the git.
Note that all these animations have a Binding that MUST toggle to be run.
struct ContentView: View {
#State private var isCircleRotating = true
#State private var animateStart = false
#State private var animateEnd = true
var body: some View {
ZStack {
Circle()
.stroke(lineWidth: 10)
.fill(Color.init(red: 0.96, green: 0.96, blue: 0.96))
.frame(width: 150, height: 150)
Circle()
.trim(from: animateStart ? 1/3 : 1/9, to: animateEnd ? 2/5 : 1)
.stroke(lineWidth: 10)
.rotationEffect(.degrees(isCircleRotating ? 360 : 0))
.frame(width: 150, height: 150)
.foregroundColor(Color.blue)
.onAppear() {
withAnimation(Animation
.linear(duration: 1)
.repeatForever(autoreverses: false)) {
self.isCircleRotating.toggle()
}
withAnimation(Animation
.linear(duration: 1)
.delay(0.5)
.repeatForever(autoreverses: true)) {
self.animateStart.toggle()
}
withAnimation(Animation
.linear(duration: 1)
.delay(1)
.repeatForever(autoreverses: true)) {
self.animateEnd.toggle()
}
}
}
}
}
Activity indicator in SwiftUI
import SwiftUI
struct Indicator: View {
#State var animateTrimPath = false
#State var rotaeInfinity = false
var body: some View {
ZStack {
Color.black
.edgesIgnoringSafeArea(.all)
ZStack {
Path { path in
path.addLines([
.init(x: 2, y: 1),
.init(x: 1, y: 0),
.init(x: 0, y: 1),
.init(x: 1, y: 2),
.init(x: 3, y: 0),
.init(x: 4, y: 1),
.init(x: 3, y: 2),
.init(x: 2, y: 1)
])
}
.trim(from: animateTrimPath ? 1/0.99 : 0, to: animateTrimPath ? 1/0.99 : 1)
.scale(50, anchor: .topLeading)
.stroke(Color.yellow, lineWidth: 20)
.offset(x: 110, y: 350)
.animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true))
.onAppear() {
self.animateTrimPath.toggle()
}
}
.rotationEffect(.degrees(rotaeInfinity ? 0 : -360))
.scaleEffect(0.3, anchor: .center)
.animation(Animation.easeInOut(duration: 1.5)
.repeatForever(autoreverses: false))
.onAppear(){
self.rotaeInfinity.toggle()
}
}
}
}
struct Indicator_Previews: PreviewProvider {
static var previews: some View {
Indicator()
}
}
I implemented the classic UIKit indicator using SwiftUI.
See the activity indicator in action here
struct ActivityIndicator: View {
#State private var currentIndex: Int = 0
func incrementIndex() {
currentIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50), execute: {
self.incrementIndex()
})
}
var body: some View {
GeometryReader { (geometry: GeometryProxy) in
ForEach(0..<12) { index in
Group {
Rectangle()
.cornerRadius(geometry.size.width / 5)
.frame(width: geometry.size.width / 8, height: geometry.size.height / 3)
.offset(y: geometry.size.width / 2.25)
.rotationEffect(.degrees(Double(-360 * index / 12)))
.opacity(self.setOpacity(for: index))
}.frame(width: geometry.size.width, height: geometry.size.height)
}
}
.aspectRatio(1, contentMode: .fit)
.onAppear {
self.incrementIndex()
}
}
func setOpacity(for index: Int) -> Double {
let opacityOffset = Double((index + currentIndex - 1) % 11 ) / 12 * 0.9
return 0.1 + opacityOffset
}
}
struct ActivityIndicator_Previews: PreviewProvider {
static var previews: some View {
ActivityIndicator()
.frame(width: 50, height: 50)
.foregroundColor(.blue)
}
}
In addition to Mojatba Hosseini's answer,
I've made a few updates so that this can be put in a swift package:
Activity indicator:
import Foundation
import SwiftUI
import UIKit
public struct ActivityIndicator: UIViewRepresentable {
public typealias UIView = UIActivityIndicatorView
public var isAnimating: Bool = true
public var configuration = { (indicator: UIView) in }
public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) {
self.isAnimating = isAnimating
if let configuration = configuration {
self.configuration = configuration
}
}
public func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView {
UIView()
}
public func updateUIView(_ uiView: UIView, context:
UIViewRepresentableContext<Self>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
configuration(uiView)
}}
Extension:
public extension View where Self == ActivityIndicator {
func configure(_ configuration: #escaping (Self.UIView) -> Void) -> Self {
Self.init(isAnimating: self.isAnimating, configuration: configuration)
}
}
It's really easy with SwiftUI 2.0 I made this simple and easy custom view with ProgressView
Here is how it looks:
Code:
import SwiftUI
struct ActivityIndicatorView: View {
#Binding var isPresented:Bool
var body: some View {
if isPresented{
ZStack{
RoundedRectangle(cornerRadius: 15).fill(CustomColor.gray.opacity(0.1))
ProgressView {
Text("Loading...")
.font(.title2)
}
}.frame(width: 120, height: 120, alignment: .center)
.background(RoundedRectangle(cornerRadius: 25).stroke(CustomColor.gray,lineWidth: 2))
}
}
}
A convenient way in SwiftUI that I found useful is 2 step approach:
Create a ViewModifier that will embed your view into ZStack and add progress indicator on top. Could be something like this:
struct LoadingIndicator: ViewModifier {
let width = UIScreen.main.bounds.width * 0.3
let height = UIScreen.main.bounds.width * 0.3
func body(content: Content) -> some View {
return ZStack {
content
.disabled(true)
.blur(radius: 2)
//gray background
VStack{}
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.background(Color.gray.opacity(0.2))
.cornerRadius(20)
.edgesIgnoringSafeArea(.all)
//progress indicator
ProgressView()
.frame(width: width, height: height)
.background(Color.white)
.cornerRadius(20)
.opacity(1)
.shadow(color: Color.gray.opacity(0.5), radius: 4.0, x: 1.0, y: 2.0)
}
}
Create view extension that will make conditional modifier application available to any view:
extension View {
/// Applies the given transform if the given condition evaluates to `true`.
/// - Parameters:
/// - condition: The condition to evaluate.
/// - transform: The transform to apply to the source `View`.
/// - Returns: Either the original `View` or the modified `View` if the condition is `true`.
#ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
Usage is very intuitive. Suppose that myView() returns whatever your view is. You just conditionally apply the modifier using .if view extension from step 2:
var body: some View {
myView()
.if(myViewModel.isLoading){ view in
view.modifier(LoadingIndicator())
}
}
In case that myViewModel.isLoading is false, no modifier will be applied, so loading indicator won't show.
Of course, you can use any kind of progress indicator you wish - default or your own custom one.
I have modified Matteo Pacini's Answer for macOS using AppKit and SwiftUI. This allows you to use NSProgressIndicator in SwiftUI while retaining capability for macOS 10.15.
import AppKit
import SwiftUI
struct ActivityIndicator: NSViewRepresentable {
#Binding var isAnimating: Bool
let style: NSProgressIndicator.Style
func makeNSView(context: NSViewRepresentableContext<ActivityIndicator>) -> NSProgressIndicator {
let progressIndicator = NSProgressIndicator()
progressIndicator.style = self.style
return progressIndicator
}
func updateNSView(_ nsView: NSProgressIndicator, context: NSViewRepresentableContext<ActivityIndicator>) {
isAnimating ? nsView.startAnimation(nil) : nsView.stopAnimation(nil)
}
}
Usage is as follows:
ActivityIndicator(isAnimating: .constant(true), style: .spinning)
Try this:
import SwiftUI
struct LoadingPlaceholder: View {
var text = "Loading..."
init(text:String ) {
self.text = text
}
var body: some View {
VStack(content: {
ProgressView(self.text)
})
}
}
More information about at SwiftUI ProgressView
// Activity View
struct ActivityIndicator: UIViewRepresentable {
let style: UIActivityIndicatorView.Style
#Binding var animate: Bool
private let spinner: UIActivityIndicatorView = {
$0.hidesWhenStopped = true
return $0
}(UIActivityIndicatorView(style: .medium))
func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
spinner.style = style
return spinner
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
animate ? uiView.startAnimating() : uiView.stopAnimating()
}
func configure(_ indicator: (UIActivityIndicatorView) -> Void) -> some View {
indicator(spinner)
return self
}
}
// Usage
struct ContentView: View {
#State var animate = false
var body: some View {
ActivityIndicator(style: .large, animate: $animate)
.configure {
$0.color = .red
}
.background(Color.blue)
}
}
my 2 cents for nice and simpler code of batuhankrbb, showing use of isPresented in timer... or other stuff... (I will use it in url callback..)
//
// ContentView.swift
//
// Created by ing.conti on 27/01/21.
import SwiftUI
struct ActivityIndicatorView: View {
#Binding var isPresented:Bool
var body: some View {
if isPresented{
ZStack{
RoundedRectangle(cornerRadius: 15).fill(Color.gray.opacity(0.1))
ProgressView {
Text("Loading...")
.font(.title2)
}
}.frame(width: 120, height: 120, alignment: .center)
.background(RoundedRectangle(cornerRadius: 25).stroke(Color.gray,lineWidth: 2))
}
}
}
struct ContentView: View {
#State var isPresented = false
#State var counter = 0
var body: some View {
VStack{
Text("Hello, world! \(counter)")
.padding()
ActivityIndicatorView(isPresented: $isPresented)
}.onAppear(perform: {
_ = startRefreshing()
})
}
func startRefreshing()->Timer{
let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
counter+=1
print(counter)
if counter>2{
isPresented = true
}
if counter>4{
isPresented = false
timer.invalidate()
}
}
return timer
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Result of Basic Activity Indicator :