SwiftUI View Overlap when integrated with Drag gesture - swiftui

I am new to swiftUI and facing an issue when working with DragGesture functionality of view. I am trying to create a photo gallery app which have swipe functionality. Facing an issue with having a gesture view embedded in HStack with other 2 views. The 1st view of HStack get covered by drag view but the 3rd view of HStack is shown on top of Drag view.
How can I make the 1st view also to be shown on top of my gesture view.
The Review screen is the view where the Gesture view is created.
struct ReviewScreen: View {
#State var swipeHorizontalDirection: SwipeHorizontalDirection = .none { didSet { print(swipeHorizontalDirection) } }
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 20) {
Image("red_rectangle")
.resizable()
.frame(width: 12, height: 100)
Image(uiImage: renderImage)
.resizable()
.frame(width: geometry.size.width * 0.75, height: geometry.size.height * 0.55)
.cornerRadius(10)
.onSwipe { direction in
switch direction {
case .right:
break
case .left:
self.presentationMode.wrappedValue.dismiss()
default:
break
}
}
Image("green_rectangle")
.resizable()
.frame(width: 12, height: 100)
}
Spacer()
}
.padding(20)
}
}
}
And the Swipe modifier code is -
struct SwipeModifier: ViewModifier {
let action: ((UISwipeGestureRecognizer.Direction) -> Void)?
#State var offset: CGSize = .zero
#State var startingOffsetX: CGFloat = UIScreen.main.bounds.width
init(perform action: ((UISwipeGestureRecognizer.Direction) -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
ZStack {
content
.offset(offset)
.scaleEffect(getScaleAmount())
.gesture(
DragGesture()
.onChanged({ value in
withAnimation(.spring()) {
offset = CGSize(width: value.translation.width, height: 0)
}
})
.onEnded({ value in
guard let action = action else {
return
}
withAnimation(.spring()) {
if value.location.x != 0,
value.startLocation.x < value.location.x && offset.width > 150 {
print("right call >> \(offset.width)")
action(.right)
}
else if value.startLocation.x > value.location.x && offset.width < -150 {
print("left call >> \(offset.width)")
action(.left)
}
offset = .zero
}
})
)
}
}
func getScaleAmount() -> CGFloat {
let max = UIScreen.main.bounds.width / 2
let currentAmount = abs(offset.width)
let percentage = currentAmount / max
return 1.0 - min(percentage, 0.5) * 0.5
}
}
extension View {
public func onSwipe(perform action: ((UISwipeGestureRecognizer.Direction) -> Void)? = nil) -> some View {
return self.modifier(SwipeModifier(perform: action))
}
}
enter image description here

Related

Touch/drag motion to select multiple cells in a lazyvgrid?

I'm trying to use a LazyVGrid in SwiftUI where you can touch and drag your finger to select multiple adjacent cells in a specific order. This is not a drag and drop and I don't want to move the cells (maybe drag isn't the right term here, but couldn't think of another term to describe it). Also, you would be able to reverse the selection (ie: each cell can only be selected once and reversing direction would un-select the cell). How can I accomplish this? Thanks!
For example:
struct ContentView: View {
#EnvironmentObject private var cellsArray: CellsArray
var body: some View {
VStack {
LazyVGrid(columns: gridItems, spacing: spacing) {
ForEach(0..<(rows * columns), id: \.self){index in
VStack(spacing: 0) {
CellsView(index: index)
}
}
}
}
}
}
struct CellsView: View {
#State var index: Int
#EnvironmentObject var cellsArray: CellsArray
var body: some View {
ZStack {
Text("\(self.cellsArray[index].cellValue)") //cellValue is a string
.foregroundColor(Color.yellow)
.frame(width: getWidth(), height: getWidth())
.background(Color.gray)
}
//.onTapGesture ???
}
func getWidth()->CGFloat{
let width = UIScreen.main.bounds.width - 10
return width / CGFloat(columns)
}
}
Something like this might help, the only issue here is the coordinate space. Overlay is not drawing the rectangle in the correct coordinate space.
struct ImagesView: View {
var columnSize: CGFloat
#Binding var projectImages: [ProjectImage]
#State var selectedImages: [ImageSelection] = []
#State var dragWidth: CGFloat = 1.0
#State var dragHeight: CGFloat = 1.0
#State var dragStart: CGPoint = CGPoint(x:0, y:0)
let columns = [
GridItem(.adaptive(minimum: 200), spacing: 0)
]
var body: some View {
GeometryReader() { geometry in
ZStack{
ScrollView{
LazyVGrid(columns: [
GridItem(.adaptive(minimum: columnSize), spacing: 2)
], spacing: 2){
ForEach(projectImages, id: \.imageUUID){ image in
Image(nsImage: NSImage(data: image.imageData)!)
.resizable()
.scaledToFit()
.border(selectedImages.contains(where: {imageSelection in imageSelection.uuid == image.imageUUID}) ? Color.blue : .primary, width: selectedImages.contains(where: {imageSelection in imageSelection.uuid == image.imageUUID}) ? 5.0 : 1.0)
.gesture(TapGesture(count: 2).onEnded{
print("Double tap finished")
})
.gesture(TapGesture(count: 1).onEnded{
if selectedImages.contains(where: {imageSelection in imageSelection.uuid == image.imageUUID}) {
print("Image is already selected")
if let index = selectedImages.firstIndex(where: {imageSelection in imageSelection.uuid == image.imageUUID}){
selectedImages.remove(at: index)
}
} else {
selectedImages.append(ImageSelection(imageUUID: image.imageUUID))
print("Image has been selected")
}
})
}
}
.frame(minHeight: 50)
.padding(.horizontal, 1)
}
.simultaneousGesture(
DragGesture(minimumDistance: 2)
.onChanged({ value in
print("Drag Start: \(value.startLocation.x)")
self.dragStart = value.startLocation
self.dragWidth = value.translation.width
self.dragHeight = value.translation.height
})
)
.overlay{
Rectangle()
.frame(width: dragWidth, height: dragHeight)
.offset(x:dragStart.x, y:dragStart.y)
}
}
}
}
}

SwiftUI Image Gallery don't pan out of visible area

What I'm trying to do is basically having some images in a TabView and being able to zoom and pan on those images.
Right now I got this quite nicely implemented but I struggle to find a solution so that it is not possible to pan outside the bounds of the image.
This is what I currently have:
I want to try having the image clip to the side of the screen so you don't pan the image out of the visible area.
So far this it what my code looks like:
struct FinalImageSwipeView: View {
#ObservedObject var viewModel: ImageChatViewModel
#State var currentImage: UUID
#State var fullPreview: Bool = false
#GestureState var draggingOffset: CGSize = .zero
var body: some View {
TabView(selection: $currentImage) {
ForEach(viewModel.images) { image in
GeometryReader{ proxy in
let size = proxy.size
PinchAndPanImage(image: UIImage(named: image.imageName)!,
fullPreview: $fullPreview)
.frame(width: size.width, height: size.height)
.contentShape(Rectangle())
}
.tag(image.id)
.ignoresSafeArea()
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
.ignoresSafeArea()
// upper navigation bar
.overlay(
ImageSwipeViewNavigationBar(fullPreview: $fullPreview, hideSwipeView: viewModel.hideSwipeView),
alignment: .top
)
// bottom image scrollview
.overlay(
ImageSwipeViewImageSelection(viewModel: viewModel,
currentImage: $currentImage,
fullPreview: $fullPreview),
alignment: .bottom
)
.gesture(DragGesture().updating($draggingOffset, body: { (value, outValue, _) in
if viewModel.imageScale == 0 {
outValue = value.translation
viewModel.onChangeDragGesture(value: draggingOffset)
}}).onEnded({ (value) in
if viewModel.imageScale == 0 {
viewModel.onEnd(value: value)
}
}))
.transition(.offset(y: UIScreen.main.bounds.size.height + 100))
}
}
struct ImageSwipeViewImageSelection: View {
#ObservedObject var viewModel: ImageChatViewModel
#Binding var currentImage: UUID
#Binding var fullPreview: Bool
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: true) {
HStack(spacing: 15) {
ForEach(viewModel.images) { image in
Image(image.imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 70, height: 60)
.cornerRadius(12)
.id(image.id)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(image.id == currentImage ? Color.white : Color.clear, lineWidth: 2)
}
.onTapGesture {
currentImage = image.id
}
}
}
.padding()
}
.frame(height: 80)
.background(BlurView(style: .systemUltraThinMaterialDark).ignoresSafeArea(edges: .bottom))
// While current post changing center current image in scrollview
.onAppear(perform: {
proxy.scrollTo(currentImage, anchor: .bottom)
})
.onChange(of: currentImage) { _ in
viewModel.imageScale = 1
withAnimation {
proxy.scrollTo(currentImage, anchor: .bottom)
}
}
}
.offset(y: fullPreview ? 150 : 0)
}
}
struct PinchAndPanImage: View {
let image: UIImage
#Binding var fullPreview: Bool
// Stuff for Pinch and Pan
#State var imageScale: CGFloat = 1
#State var imageCurrentScale: CGFloat = 0
#State var imagePanOffset: CGSize = .zero
#State var currentImagePanOffset: CGSize = .zero
var usedImageScale: CGFloat {
max(1, min(imageScale + imageCurrentScale, 10))
}
var usedImagePan: CGSize {
let width = imagePanOffset.width + currentImagePanOffset.width
let height = imagePanOffset.height + currentImagePanOffset.height
return CGSize(width: width, height: height)
}
var body: some View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(0)
.offset(usedImagePan)
.scaleEffect(usedImageScale > 1 ? usedImageScale : 1)
.gesture(
// Magnifying Gesture
MagnificationGesture()
.onChanged({ value in
imageCurrentScale = value - 1
})
.onEnded({ value in
imageCurrentScale = 0
imageScale = imageScale + value - 1
withAnimation(.easeInOut) {
if imageScale > 5 {
imageScale = 5
}
}
})
)
.simultaneousGesture(createPanGesture())
.onTapGesture(count: 2) {
withAnimation {
imageScale = 1
imagePanOffset = .zero
}
}
.onTapGesture(count: 1) {
withAnimation {
fullPreview.toggle()
}
}
}
private func createPanGesture() -> _EndedGesture<_ChangedGesture<DragGesture>>? {
let gesture = DragGesture()
.onChanged { value in
let width = value.translation.width / usedImageScale
let height = value.translation.height / usedImageScale
currentImagePanOffset = CGSize(width: width, height: height)
}
.onEnded { value in
currentImagePanOffset = .zero
let scaledWidth = value.translation.width / usedImageScale
let scaledHeight = value.translation.height / usedImageScale
let width = imagePanOffset.width + scaledWidth
let height = imagePanOffset.height + scaledHeight
imagePanOffset = CGSize(width: width, height: height)
}
return imageScale > 1 ? gesture : nil
}
}

Is it possible to make dynamically VStack in the HStack when screen width ends [duplicate]

Is it possible that the blue tags (which are currently truncated) are displayed completely and then it automatically makes a line break?
NavigationLink(destination: GameListView()) {
VStack(alignment: .leading, spacing: 5){
// Name der Sammlung:
Text(collection.name)
.font(.headline)
// Optional: Für welche Konsolen bzw. Plattformen:
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
.lineLimit(1)
}
}
}
.padding(.vertical, 10)
}
Also, there should be no line breaks with in the blue tags:
That's how it should look in the end:
Here is some approach of how this could be done using alignmentGuide(s). It is simplified to avoid many code post, but hope it is useful.
Update: There is also updated & improved variant of below solution in my answer for SwiftUI HStack with wrap and dynamic height
This is the result:
And here is full demo code (orientation is supported automatically):
import SwiftUI
struct TestWrappedLayout: View {
#State var platforms = ["Ninetendo", "XBox", "PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4"]
var body: some View {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
private func generateContent(in g: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.platforms, id: \.self) { platform in
self.item(for: platform)
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if platform == self.platforms.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if platform == self.platforms.last! {
height = 0 // last item
}
return result
})
}
}
}
func item(for text: String) -> some View {
Text(text)
.padding(.all, 5)
.font(.body)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}
struct TestWrappedLayout_Previews: PreviewProvider {
static var previews: some View {
TestWrappedLayout()
}
}
For me, none of the answers worked. Either because I had different types of elements or because elements around were not being positioned correctly. Therefore, I ended up implementing my own WrappingHStack which can be used in a very similar way to HStack. You can find it at GitHub: WrappingHStack.
Here is an example:
Code:
WrappingHStack {
Text("WrappingHStack")
.padding()
.font(.title)
.border(Color.black)
Text("can handle different element types")
Image(systemName: "scribble")
.font(.title)
.frame(width: 200, height: 20)
.background(Color.purple)
Text("and loop")
.bold()
WrappingHStack(1...20, id:\.self) {
Text("Item: \($0)")
.padding(3)
.background(Rectangle().stroke())
}.frame(minWidth: 250)
}
.padding()
.border(Color.black)
I've had ago at creating what you need.
Ive used HStack's in a VStack.
You pass in a geometryProxy which is used for determining the maximum row width.
I went with passing this in so it would be usable within a scrollView
I wrapped the SwiftUI Views in a UIHostingController to get a size for each child.
I then loop through the views adding them to the row until it reaches the maximum width, in which case I start adding to a new row.
This is just the init and final stage combining and outputting the rows in the VStack
struct WrappedHStack<Content: View>: View {
private let content: [Content]
private let spacing: CGFloat = 8
private let geometry: GeometryProxy
init(geometry: GeometryProxy, content: [Content]) {
self.content = content
self.geometry = geometry
}
var body: some View {
let rowBuilder = RowBuilder(spacing: spacing,
containerWidth: geometry.size.width)
let rowViews = rowBuilder.generateRows(views: content)
let finalView = ForEach(rowViews.indices) { rowViews[$0] }
VStack(alignment: .center, spacing: 8) {
finalView
}.frame(width: geometry.size.width)
}
}
extension WrappedHStack {
init<Data, ID: Hashable>(geometry: GeometryProxy, #ViewBuilder content: () -> ForEach<Data, ID, Content>) {
let views = content()
self.geometry = geometry
self.content = views.data.map(views.content)
}
init(geometry: GeometryProxy, content: () -> [Content]) {
self.geometry = geometry
self.content = content()
}
}
The magic happens in here
extension WrappedHStack {
struct RowBuilder {
private var spacing: CGFloat
private var containerWidth: CGFloat
init(spacing: CGFloat, containerWidth: CGFloat) {
self.spacing = spacing
self.containerWidth = containerWidth
}
func generateRows<Content: View>(views: [Content]) -> [AnyView] {
var rows = [AnyView]()
var currentRowViews = [AnyView]()
var currentRowWidth: CGFloat = 0
for (view) in views {
let viewWidth = view.getSize().width
if currentRowWidth + viewWidth > containerWidth {
rows.append(createRow(for: currentRowViews))
currentRowViews = []
currentRowWidth = 0
}
currentRowViews.append(view.erasedToAnyView())
currentRowWidth += viewWidth + spacing
}
rows.append(createRow(for: currentRowViews))
return rows
}
private func createRow(for views: [AnyView]) -> AnyView {
HStack(alignment: .center, spacing: spacing) {
ForEach(views.indices) { views[$0] }
}
.erasedToAnyView()
}
}
}
and here's extensions I used
extension View {
func erasedToAnyView() -> AnyView {
AnyView(self)
}
func getSize() -> CGSize {
UIHostingController(rootView: self).view.intrinsicContentSize
}
}
You can see the full code with some examples here:
https://gist.github.com/kanesbetas/63e719cb96e644d31bf027194bf4ccdb
I have something like this code (rather long). In simple scenarios it works ok, but in deep nesting with geometry readers it doesn't propagate its size well.
It would be nice if this views wraps and flows like Text() extending parent view content, but it seems to have explicitly set its height from parent view.
https://gist.github.com/michzio/a0b23ee43a88cbc95f65277070167e29
Here is the most important part of the code (without preview and test data)
private func flow(in geometry: GeometryProxy) -> some View {
print("Card geometry: \(geometry.size.width) \(geometry.size.height)")
return ZStack(alignment: .topLeading) {
//Color.clear
ForEach(data, id: self.dataId) { element in
self.content(element)
.geometryPreference(tag: element\[keyPath: self.dataId\])
/*
.alignmentGuide(.leading) { d in
print("Element: w: \(d.width), h: \(d.height)")
if (abs(width - d.width) > geometry.size.width)
{
width = 0
height -= d.height
}
let result = width
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
width = 0 //last item
} else {
width -= d.width
}
return result
}
.alignmentGuide(.top) { d in
let result = height
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
height = 0 // last item
}
return result
}*/
.alignmentGuide(.top) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.y ?? 0
}
.alignmentGuide(.leading) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.x ?? 0
}
}
}
.background(Color.pink)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
//.animation(self.loaded ? .linear(duration: 1) : nil)
.onPreferenceChange(_GeometryPreferenceKey.self, perform: { preferences in
DispatchQueue.main.async {
let (alignmentGuides, totalHeight) = self.calculateAlignmentGuides(preferences: preferences, geometry: geometry)
self.alignmentGuides = alignmentGuides
self.totalHeight = totalHeight
self.availableWidth = geometry.size.width
}
})
}
func calculateAlignmentGuides(preferences: \[_GeometryPreference\], geometry: GeometryProxy) -> (\[AnyHashable: CGPoint\], CGFloat) {
var alignmentGuides = \[AnyHashable: CGPoint\]()
var width: CGFloat = 0
var height: CGFloat = 0
var rowHeights: Set<CGFloat> = \[\]
preferences.forEach { preference in
let elementWidth = spacing + preference.rect.width
if width + elementWidth >= geometry.size.width {
width = 0
height += (rowHeights.max() ?? 0) + spacing
//rowHeights.removeAll()
}
let offset = CGPoint(x: 0 - width, y: 0 - height)
print("Alignment guides offset: \(offset)")
alignmentGuides\[preference.tag\] = offset
width += elementWidth
rowHeights.insert(preference.rect.height)
}
return (alignmentGuides, height + (rowHeights.max() ?? 0))
}
}
I had the same problem I've, to solve it I pass the object item to a function which first creates the view for the item, then through the UIHostController I will calculate the next position based on the items width. the items view is then returned by the function.
import SwiftUI
class TestItem: Identifiable {
var id = UUID()
var str = ""
init(str: String) {
self.str = str
}
}
struct AutoWrap: View {
var tests: [TestItem] = [
TestItem(str:"Ninetendo"),
TestItem(str:"XBox"),
TestItem(str:"PlayStation"),
TestItem(str:"PlayStation 2"),
TestItem(str:"PlayStation 3"),
TestItem(str:"random"),
TestItem(str:"PlayStation 4"),
]
var body: some View {
var curItemPos: CGPoint = CGPoint(x: 0, y: 0)
var prevItemWidth: CGFloat = 0
return GeometryReader { proxy in
ZStack(alignment: .topLeading) {
ForEach(tests) { t in
generateItem(t: t, curPos: &curItemPos, containerProxy: proxy, prevItemWidth: &prevItemWidth)
}
}.padding(5)
}
}
func generateItem(t: TestItem, curPos: inout CGPoint, containerProxy: GeometryProxy, prevItemWidth: inout CGFloat, hSpacing: CGFloat = 5, vSpacing: CGFloat = 5) -> some View {
let viewItem = Text(t.str).padding([.leading, .trailing], 15).background(Color.blue).cornerRadius(25)
let itemWidth = UIHostingController(rootView: viewItem).view.intrinsicContentSize.width
let itemHeight = UIHostingController(rootView: viewItem).view.intrinsicContentSize.height
let newPosX = curPos.x + prevItemWidth + hSpacing
let newPosX2 = newPosX + itemWidth
if newPosX2 > containerProxy.size.width {
curPos.x = hSpacing
curPos.y += itemHeight + vSpacing
} else {
curPos.x = newPosX
}
prevItemWidth = itemWidth
return viewItem.offset(x: curPos.x, y: curPos.y)
}
}
struct AutoWrap_Previews: PreviewProvider {
static var previews: some View {
AutoWrap()
}
}
iOS 16 has a new Layout protocol that's perfect for that task. I've written a library with the line-wrapping behavior. It can handle different types of subviews and alignment guide values.
You need to handle line configurations right after Text View. Don't use lineLimit(1) if you need multiple lines.
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(10)
.multilineTextAlignment(.leading)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}

Weird behaviour of SwiftUI Picker View when the other view getting new value from AVCaptureDevice API

I'm new to SwiftUI and Combine. What I trying to build is a manual camera app, and there's only 4 UI component:
CaptureButton for making a shot from the camera
FocusPicker for controlling manually camera focus exposure
OffsetView for displaying a level of exposure
CameraPreviewRepresentable for integrating UIKit camera into SwiftUI view
Also added Privacy requests into.Info.plist file from a user to allow camera feature and saving to Apple Photo App
For updating data and passing it to the UI, I'm using CameraViewModel, currentCameraSubject and currentCamera Publisher to showing new values from AVCaptureDevice and setting it to CameraViewModel.
And I'm noticing a really interesting behavior/bug of FocusPicker when I start interacting with it and piking a new focus it constantly get back to started position and when OffsetView is getting a new value each time.
But interesting enough for example when OffsetView has the same value then FocusPicker is doing normal. And I do not know why this is happening. Please help, it's really frustrating to fix for me.
By the way, it will only work on a real device only.
Here's all the code:
import SwiftUI
//#main
//struct StackOverflowCamApp: App {
// var cameraViewModel = CameraViewModel(focusLensPosition: 0)
// let cameraController: CustomCameraController = CustomCameraController()
//
// var body: some Scene {
// WindowGroup {
// ContentView(cameraViewModel: cameraViewModel, cameraController: cameraController)
// }
// }
//}
struct ContentView: View {
#State private var didTapCapture = false
#ObservedObject var cameraViewModel: CameraViewModel
let cameraController: CustomCameraController
var body: some View {
VStack {
ZStack {
CameraPreviewRepresentable(didTapCapture: $didTapCapture, cameraViewModel: cameraViewModel, cameraController: cameraController)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
VStack {
FocusPicker(selectedFocus: $cameraViewModel.focusChoice)
Text(String(format: "%.2f", cameraViewModel.focusLensPosition))
.foregroundColor(.red)
.font(.largeTitle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.edgesIgnoringSafeArea(.all)
Spacer()
OffsetView(levelValue: cameraViewModel.exposureTargetOffset, height: 100)
.frame(maxWidth: .infinity, alignment: .leading)
CaptureButton(didTapCapture: $didTapCapture)
.frame(width: 100, height: 100, alignment: .center)
.padding(.bottom, 20)
}
}
}
struct CaptureButton: View {
#Binding var didTapCapture : Bool
var body: some View {
Button {
didTapCapture.toggle()
} label: {
Image(systemName: "photo")
.font(.largeTitle)
.padding(30)
.background(Color.red)
.foregroundColor(.white)
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.red)
)
}
}
}
struct OffsetView: View {
var levelValue: Float
let height: CGFloat
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.red)
.frame(maxWidth: height / 2, maxHeight: height, alignment: .trailing)
Rectangle()
.foregroundColor(.orange)
.frame(maxWidth: height / 2, maxHeight: height / 20, alignment: .trailing)
.offset(x: 0, y: min(CGFloat(-levelValue) * height / 2, height / 2))
}
}
}
struct FocusPicker: View {
#Binding var selectedFocus: FocusChoice
var body: some View {
Picker(selection: $selectedFocus, label: Text("")) {
ForEach(0..<FocusChoice.allCases.count) {
Text("\(FocusChoice.allCases[$0].caption)")
.foregroundColor(.white)
.font(.subheadline)
.fontWeight(.medium)
.tag(FocusChoice.allCases[$0])
}
.animation(.none)
.background(Color.clear)
.pickerStyle(WheelPickerStyle())
}
.frame(width: 60, height: 200)
.border(Color.gray, width: 5)
.clipped()
}
}
import SwiftUI
import Combine
import AVFoundation
struct CameraPreviewRepresentable: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
#Binding var didTapCapture: Bool
#ObservedObject var cameraViewModel: CameraViewModel
let cameraController: CustomCameraController
func makeUIViewController(context: Context) -> CustomCameraController {
cameraController.delegate = context.coordinator
return cameraController
}
func updateUIViewController(_ cameraViewController: CustomCameraController, context: Context) {
if didTapCapture {
cameraViewController.didTapRecord()
}
// checking if new value is differnt from the previous value
if cameraViewModel.focusChoice.rawValue != cameraViewController.manualFocusValue {
cameraViewController.manualFocusValue = cameraViewModel.focusChoice.rawValue
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self, cameraViewModel: cameraViewModel)
}
class Coordinator: NSObject, UINavigationControllerDelegate, AVCapturePhotoCaptureDelegate {
let parent: CameraPreviewRepresentable
var cameraViewModel: CameraViewModel
var tokens = Set<AnyCancellable>()
init(_ parent: CameraPreviewRepresentable, cameraViewModel: CameraViewModel) {
self.parent = parent
self.cameraViewModel = cameraViewModel
super.init()
// for showing focus lens position
self.parent.cameraController.currentCamera
.filter { $0 != nil }
.flatMap { $0!.publisher(for: \.lensPosition) }
.assign(to: \.focusLensPosition, on: cameraViewModel)
.store(in: &tokens)
// for showing exposure offset
self.parent.cameraController.currentCamera
.filter { $0 != nil }
.flatMap { $0!.publisher(for: \.exposureTargetOffset) }
.assign(to: \.exposureTargetOffset, on: cameraViewModel)
.store(in: &tokens)
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
parent.didTapCapture = false
if let imageData = photo.fileDataRepresentation(), let image = UIImage(data: imageData) {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
import Combine
import AVFoundation
class CameraViewModel: ObservableObject {
#Published var focusLensPosition: Float = 0
#Published var exposureTargetOffset: Float = 0
#Published var focusChoice: FocusChoice = .infinity
private var tokens = Set<AnyCancellable>()
init(focusLensPosition: Float) {
self.focusLensPosition = focusLensPosition
}
}
enum FocusChoice: Float, CaseIterable {
case infinity = 1
case ft_30 = 0.95
case ft_15 = 0.9
case ft_10 = 0.85
case ft_7 = 0.8
case ft_5 = 0.5
case ft_4 = 0.7
case ft_3_5 = 0.65
case ft_3 = 0.6
case auto = 0
}
extension FocusChoice {
var caption: String {
switch self {
case .infinity: return "∞ft"
case .ft_30: return "30"
case .ft_15: return "15"
case .ft_10: return "10"
case .ft_7: return "7"
case .ft_5: return "5"
case .ft_4: return "4"
case .ft_3_5: return "3.5"
case .ft_3: return "3"
case .auto: return "Auto"
}
}
}
import UIKit
import Combine
import AVFoundation
class CustomCameraController: UIViewController {
var image: UIImage?
var captureSession = AVCaptureSession()
var backCamera: AVCaptureDevice?
var frontCamera: AVCaptureDevice?
lazy var currentCamera: AnyPublisher<AVCaptureDevice?, Never> = currentCameraSubject.eraseToAnyPublisher()
var photoOutput: AVCapturePhotoOutput?
var cameraPreviewLayer: AVCaptureVideoPreviewLayer?
private var currentCameraSubject = CurrentValueSubject<AVCaptureDevice?, Never>(nil)
var manualFocusValue: Float = 1 {
didSet {
guard manualFocusValue != 0 else {
setAutoLensPosition()
return
}
setFocusLensPosition(manualValue: manualFocusValue)
}
}
//DELEGATE
var delegate: AVCapturePhotoCaptureDelegate?
func setFocusLensPosition(manualValue: Float) {
do {
try currentCameraSubject.value!.lockForConfiguration()
currentCameraSubject.value!.focusMode = .locked
currentCameraSubject.value!.setFocusModeLocked(lensPosition: manualValue, completionHandler: nil)
currentCameraSubject.value!.unlockForConfiguration()
} catch let error {
print(error.localizedDescription)
}
}
func setAutoLensPosition() {
do {
try currentCameraSubject.value!.lockForConfiguration()
currentCameraSubject.value!.focusMode = .continuousAutoFocus
currentCameraSubject.value!.unlockForConfiguration()
} catch let error {
print(error.localizedDescription)
}
}
func didTapRecord() {
let settings = AVCapturePhotoSettings()
photoOutput?.capturePhoto(with: settings, delegate: delegate!)
}
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
func setup() {
setupCaptureSession()
setupDevice()
setupInputOutput()
setupPreviewLayer()
startRunningCaptureSession()
}
func setupCaptureSession() {
captureSession.sessionPreset = .photo
}
func setupDevice() {
let deviceDiscoverySession =
AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera],
mediaType: .video,
position: .unspecified)
for device in deviceDiscoverySession.devices {
switch device.position {
case .front:
self.frontCamera = device
case .back:
self.backCamera = device
default:
break
}
}
self.currentCameraSubject.send(self.backCamera)
}
func setupInputOutput() {
do {
let captureDeviceInput = try AVCaptureDeviceInput(device: currentCameraSubject.value!)
captureSession.addInput(captureDeviceInput)
photoOutput = AVCapturePhotoOutput()
captureSession.addOutput(photoOutput!)
} catch {
print(error)
}
}
func setupPreviewLayer() {
self.cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
self.cameraPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
let deviceOrientation = UIDevice.current.orientation
cameraPreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation(rawValue: deviceOrientation.rawValue)!
self.cameraPreviewLayer?.frame = self.view.frame
self.view.layer.insertSublayer(cameraPreviewLayer!, at: 0)
}
func startRunningCaptureSession() {
captureSession.startRunning()
}
}
Your ContentView gets updated all the time from the Published values. To Fix that we first remove the declaration as ObservedObject from the ViewModel inside the ContentView and declare it like that:
let cameraViewModel: CameraViewModel
Now we will get some errors. For FocusView just use ProxyBinding.
FocusPicker(selectedFocus: Binding<FocusChoice>(
get: {
cameraViewModel.focusChoice
},
set: {
cameraViewModel.focusChoice = $0
}
))
For the updated text, just create another View. Here! we use ObservedObject.
struct TextView: View {
#ObservedObject var cameraViewModel: CameraViewModel
var body: some View {
Text(String(format: "%.2f", cameraViewModel.focusLensPosition))
.foregroundColor(.red)
.font(.largeTitle)
}
}
Same for the OffsetView. Add ObservedObject there
struct OffsetView: View {
#ObservedObject var viewModel : CameraViewModel
let height: CGFloat
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.red)
.frame(maxWidth: height / 2, maxHeight: height, alignment: .trailing)
Rectangle()
.foregroundColor(.orange)
.frame(maxWidth: height / 2, maxHeight: height / 20, alignment: .trailing)
.offset(x: 0, y: min(CGFloat(-viewModel.exposureTargetOffset) * height / 2, height / 2))
}
}
}
The ContentView will then look like that:
struct ContentView: View {
#State private var didTapCapture = false
let cameraViewModel: CameraViewModel
let cameraController: CustomCameraController
var body: some View {
VStack {
ZStack {
CameraPreviewRepresentable(didTapCapture: $didTapCapture, cameraViewModel: cameraViewModel, cameraController: cameraController)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
VStack {
FocusPicker(selectedFocus: Binding<FocusChoice>(
get: {
cameraViewModel.focusChoice
},
set: {
cameraViewModel.focusChoice = $0
}
))
TextView(cameraViewModel: cameraViewModel)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.edgesIgnoringSafeArea(.all)
Spacer()
OffsetView(viewModel: cameraViewModel, height: 100)
.frame(maxWidth: .infinity, alignment: .leading)
CaptureButton(didTapCapture: $didTapCapture)
.frame(width: 100, height: 100, alignment: .center)
.padding(.bottom, 20)
}
}
}
Hence, we do not have any ObservedObject anymore in the ContentView and our Picker just works fine.

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 :