SwiftUI: Place and drag object immediately - swiftui

I am attempting to place an object (a view of a square) on the screen and then drag it immediately. What I have achieved is the following:
I can drag existing objects that are already on the screen.
I can place new objects on the screen, but they do not drag along immediately. I need to lift my finger, and then tap on them again in order to drag.
How can I achieve the functionality: Place object on screen and immediately start dragging? I am missing something here. My code:
ContentView with square placement logic:
struct ContentView: View {
let screenSize = UIScreen.main.bounds
#State private var squares: [SquareView] = []
#State private var offsets = [CGSize](repeating: .zero, count: 300)
var body: some View {
GeometryReader { geo in
ForEach(squares, id: \.self) { square in
square
.position(x: square.startXLocation, y: square.startYLocation)
}
.ignoresSafeArea()
}
.onTouch(perform: updateLocation)
.onAppear {
for i in 0...2 {
let xLocation = Double.random(in: 50...(screenSize.width - 150))
let yLocation = Double.random(in: 50...(screenSize.height - 150))
let square = SquareView(sideLength: 40, number: i, startXLocation: xLocation, startYLocation: yLocation)
squares.append(square)
}
}
}
func updateLocation(_ location: CGPoint, type: TouchType) {
var square = SquareView(sideLength: 50, number: Int.random(in: 20...99), startXLocation: location.x, startYLocation: location.y)
if type == .started {
squares.append(square)
square.startXLocation = location.x
square.startYLocation = location.y
}
if type == .moved {
let newSquare = squares.last!
newSquare.offset = CGSize(width: location.x - newSquare.startXLocation, height: location.y - newSquare.startYLocation)
}
if type == .ended {
// Don't need to do anything here
}
}
}
The squares that I place with the logic to drag on the screen:
struct SquareView: View, Hashable {
let colors: [Color] = [.green, .red, .blue, .yellow]
let sideLength: Double
let number: Int
var startXLocation: Double
var startYLocation: Double
#State private var squareColor: Color = .yellow
#State var startOffset: CGSize = .zero
#State var offset: CGSize = .zero
var body: some View {
ZStack{
Rectangle()
.frame(width: sideLength, height: sideLength)
.foregroundColor(squareColor)
.onAppear {
squareColor = colors.randomElement()!
}
Text("\(number)")
} // ZStack
.offset(offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset.width = gesture.translation.width + startOffset.width
offset.height = gesture.translation.height + startOffset.height
}
.onEnded { value in
startOffset.width = value.location.x
startOffset.height = value.location.y
}
)
}
static func ==(lhs: SquareView, rhs: SquareView) -> Bool {
return lhs.number == rhs.number
}
func hash(into hasher: inout Hasher) {
hasher.combine(number)
}
}
The struct used to detect the touch location on the screen (not relevant for the actual question, but necessary to reconstruct the program). Adapted from code by Paul Hudson, hackingwithswift.com:
// The types of touches users want to be notified about
struct TouchType: OptionSet {
let rawValue: Int
static let started = TouchType(rawValue: 1 << 0)
static let moved = TouchType(rawValue: 1 << 1)
static let ended = TouchType(rawValue: 1 << 2)
static let all: TouchType = [.started, .moved, .ended]
}
// Our UIKit to SwiftUI wrapper view
struct TouchLocatingView: UIViewRepresentable {
// A closer to call when touch data has arrived
var onUpdate: (CGPoint, TouchType) -> Void
// The list of touch types to be notified of
var types = TouchType.all
// Whether touch information should continue after the user's finger has left the view
var limitToBounds = true
func makeUIView(context: Context) -> TouchLocatingUIView {
// Create the underlying UIView, passing in our configuration
let view = TouchLocatingUIView()
view.onUpdate = onUpdate
view.touchTypes = types
view.limitToBounds = limitToBounds
return view
}
func updateUIView(_ uiView: TouchLocatingUIView, context: Context) {
}
// The internal UIView responsible for catching taps
class TouchLocatingUIView: UIView {
// Internal copies of our settings
var onUpdate: ((CGPoint, TouchType) -> Void)?
var touchTypes: TouchType = .all
var limitToBounds = true
// Our main initializer, making sure interaction is enabled.
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
}
// Just in case you're using storyboards!
required init?(coder: NSCoder) {
super.init(coder: coder)
isUserInteractionEnabled = true
}
// Triggered when a touch starts.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .started)
}
// Triggered when an existing touch moves.
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .moved)
}
// Triggered when the user lifts a finger.
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .ended)
}
// Triggered when the user's touch is interrupted, e.g. by a low battery alert.
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .ended)
}
// Send a touch location only if the user asked for it
func send(_ location: CGPoint, forEvent event: TouchType) {
guard touchTypes.contains(event) else {
return
}
if limitToBounds == false || bounds.contains(location) {
onUpdate?(CGPoint(x: round(location.x), y: round(location.y)), event)
}
}
}
}
// A custom SwiftUI view modifier that overlays a view with our UIView subclass.
struct TouchLocater: ViewModifier {
var type: TouchType = .all
var limitToBounds = true
let perform: (CGPoint, TouchType) -> Void
func body(content: Content) -> some View {
content
.background(
TouchLocatingView(onUpdate: perform, types: type, limitToBounds: limitToBounds)
)
// .overlay(
// TouchLocatingView(onUpdate: perform, types: type, limitToBounds: limitToBounds)
// )
}
}
// A new method on View that makes it easier to apply our touch locater view.
extension View {
func onTouch(type: TouchType = .all, limitToBounds: Bool = true, perform: #escaping (CGPoint, TouchType) -> Void) -> some View {
self.modifier(TouchLocater(type: type, limitToBounds: limitToBounds, perform: perform))
}
}
// Finally, here's some example code you can try out.
struct ContentView1: View {
var body: some View {
VStack {
Text("This will track all touches, inside bounds only.")
.padding()
.background(.red)
.onTouch(perform: updateLocation)
Text("This will track all touches, ignoring bounds – you can start a touch inside, then carry on moving it outside.")
.padding()
.background(.blue)
.onTouch(limitToBounds: false, perform: updateLocation)
Text("This will track only starting touches, inside bounds only.")
.padding()
.background(.green)
.onTouch(type: .started, perform: updateLocation)
}
}
func updateLocation(_ location: CGPoint, type: TouchType) {
print(location, type)
}
}

A possible approach is to handle drag and creation in "area" (background container), while "item" views are just rendered at the place where needed.
Find below a simplified demo (used Xcode 13.2 / iOS 15.2), see also comments in code snapshot.
Note: tap detection in already "existed" item is an exercise for you.
extension CGPoint: Identifiable { // just a helper for demo
public var id: String { "\(x)-\(y)" }
}
struct TapAndDragDemo: View {
#State private var points: [CGPoint] = [] // << persistent
#State private var point: CGPoint? // << current
#GestureState private var dragState: CGSize = CGSize.zero
var body: some View {
Color.clear.overlay( // << area
Group {
ForEach(points) { // << stored `items`
Rectangle()
.frame(width: 24, height: 24)
.position(x: $0.x, y: $0.y)
}
if let curr = point { // << active `item`
Rectangle().fill(Color.red)
.frame(width: 24, height: 24)
.position(x: curr.x, y: curr.y)
}
}
)
.contentShape(Rectangle()) // << make area tappable
.gesture(DragGesture(minimumDistance: 0.0)
.updating($dragState) { drag, state, _ in
state = drag.translation
}
.onChanged {
point = $0.location // track drag current
}
.onEnded {
points.append($0.location) // push to stored
point = nil
}
)
}
}

Related

How to make custom .sheet viewmodifier

I am trying to recreate the native .sheet() view modifier in SwiftUI. When I look at the definition, I get below function, but I'm not sure where to go from there.
The .sheet somehow passes a view WITH bindings to a distant parent at the top of the view-tree, but I can't see how that is done. If you use PreferenceKey with an AnyView, you can't have bindings.
My usecase is that I want to define a sheet in a subview, but I want to activate it at a distant parent-view to avoid it interfering with other code.
func showSheet<Content>(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> Content) -> some View where Content : View {
// What do I put here?
}
So, I ended up doing my own sheet in SwiftUI using a preferenceKey for passing the view up the view-tree, and an environmentObject for passing the binding for showing/hiding the sheet back down again.
It's a bit long-winded, but here's the gist of it:
struct HomeOverlays<Content: View>: View {
#Binding var showSheet:Bool
#State private var sheet:EquatableViewContainer = EquatableViewContainer(id: "original", view: AnyView(Text("No view")))
#State private var animatedSheet:Bool = false
#State private var dragPercentage:Double = 0 /// 1 = fully visible, 0 = fully hidden
// Content
let content: Content
init(_ showSheet: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) {
self._showSheet = showSheet
self.content = content()
}
var body: some View {
GeometryReader { geometry in
ZStack {
content
.blur(radius: 5 * dragPercentage)
.opacity(1 - dragPercentage * 0.5)
.disabled(showSheet)
.scaleEffect(1 - 0.1 * dragPercentage)
.frame(width: geometry.size.width, height: geometry.size.height)
if animatedSheet {
sheet.view
.background(Color.greyB.opacity(0.5).edgesIgnoringSafeArea(.bottom))
.cornerRadius(5)
.transition(.move(edge: .bottom).combined(with: .opacity))
.dragToSnap(snapPercentage: 0.3, dragPercentage: $dragPercentage) { showSheet = false } /// Custom modifier for measuring how far the view is dragged down. If more than 30% it snaps showSheet to false, and otherwise it snaps it back up again
.edgesIgnoringSafeArea(.bottom)
}
}
.onPreferenceChange(HomeOverlaySheet.self, perform: { value in self.sheet = value } )
.onChange(of: showSheet) { show in sheetUpdate(show) }
}
}
func sheetUpdate(_ show:Bool) {
withAnimation(.easeOut(duration: 0.2)) {
self.animatedSheet = show
if show { dragPercentage = 1 } else { dragPercentage = 0 }
}
// Delay onDismiss action if removing sheet, so animation can complete
if show == false {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
sheet.action()
}
}
}
}
struct HomeOverlays_Previews: PreviewProvider {
static var previews: some View {
HomeOverlays(.constant(false)) {
Text("Home overlays")
}
}
}
// MARK: Preference key for passing view up the tree
struct HomeOverlaySheet: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(id: "default", view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
if value != nextValue() && nextValue().id != "default" {
value = nextValue()
}
}
}
// MARK: View extension for defining view somewhere in view tree
extension View {
// Change only leading view
func homeSheet<SheetView: View>(onDismiss action: #escaping () -> Void, #ViewBuilder sheet: #escaping () -> SheetView) -> some View {
let sheet = sheet()
return
self
.preference(key: HomeOverlaySheet.self, value: EquatableViewContainer(view: AnyView( sheet ), action: action ))
}
}

How to create synced ScrollViews in SwiftUI

I am trying to create two synced ScrollViews in SwiftUI such that scrolling in one will result in the same scrolling in the other.
I am using the ScrollViewOffset class shown at the bottom for getting a scrollView offset value but having trouble figuring out how to scroll the other view.
I seem to be able to 'hack' it by preventing scrolling in one view and setting the content position() on the other - is there any way to actually scroll the scrollView content to a position - I know ScrollViewReader seems to allow scrolling to display content items but I can't seem to find anything that will scroll the contents to an offset position.
The problem with using position() is that it does not actually change the ScrollViews scroller positions - there seems to be no ScrollView.scrollContentsTo(point: CGPoint).
#State private var scrollOffset1: CGPoint = .zero
HStack {
ScrollViewOffset(onOffsetChange: { offset in
scrollOffset1 = offset
print("New ScrollView1 offset: \(offset)")
}, content: {
VStack {
ImageView(filteredImageProvider: self.provider)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
})
ScrollView([]) {
VStack {
ImageView(filteredImageProvider: self.provider, showEdits: false)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
.position(x: scrollOffset1.x, y: scrollOffset1.y + (imageHeight + (geometry.size.height - 20) * 2)/2)
}
}
//
// ScrollViewOffset.swift
// ZoomView
//
//
import Foundation
import SwiftUI
struct ScrollViewOffset<Content: View>: View {
let onOffsetChange: (CGPoint) -> Void
let content: () -> Content
init(
onOffsetChange: #escaping (CGPoint) -> Void,
#ViewBuilder content: #escaping () -> Content
) {
self.onOffsetChange = onOffsetChange
self.content = content
}
var body: some View {
ScrollView([.horizontal, .vertical]) {
offsetReader
content()
.padding(.top, -8)
}
.coordinateSpace(name: "frameLayer")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: onOffsetChange)
}
var offsetReader: some View {
GeometryReader { proxy in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: proxy.frame(in: .named("frameLayer")).origin
)
}
.frame(width: 0, height: 0)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
Synced scroll views.
In this example, you can scroll the LHS scrollview and the RHS scrollview will be synchronised to the same position. In this example, the scrollview on the RHS is disabled, and the position is simply synchronised by using an offset.
But using the same logic and code, you can make both the LHS and RHS scrollviews synced when either of them are scrolled.
import SwiftUI
struct ContentView: View {
#State private var offset = CGFloat.zero
var body: some View {
HStack(alignment: .top) {
// MainScrollView
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.background( GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { value in
print("offset >> \(value)")
offset = value
}
}
.coordinateSpace(name: "scroll")
// Synchronised with ScrollView above
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.offset(y: -offset)
}
.disabled(true)
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
With the current API of ScrollView, this is not possible. While you can get the contentOffset of the scrollView using methods that are widely available on the internet, the ScrollViewReader that is used to programmatically scroll a ScrollView only allows you to scroll to specific views, instead of to a contentOffset.
To achieve this functionality, you are going to have to wrap UIScrollView. Here is an implementation, although it isn't 100% stable, and is missing a good amount of scrollView functionality:
import SwiftUI
import UIKit
public struct ScrollableView<Content: View>: UIViewControllerRepresentable {
#Binding var offset: CGPoint
var content: () -> Content
public init(_ offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self._offset = offset
self.content = content
}
public func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
vc.scrollView.setContentOffset(offset, animated: false)
vc.delegate = context.coordinator
return vc
}
public func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
// Allow for deaceleration to be done by the scrollView
if !viewController.scrollView.isDecelerating {
viewController.scrollView.setContentOffset(offset, animated: false)
}
}
public func makeCoordinator() -> Coordinator {
Coordinator(contentOffset: _offset)
}
public class Coordinator: NSObject, UIScrollViewDelegate {
let contentOffset: Binding<CGPoint>
init(contentOffset: Binding<CGPoint>) {
self.contentOffset = contentOffset
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentOffset.wrappedValue = scrollView.contentOffset
}
}
}
public class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = UIScrollView()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
weak var delegate: UIScrollViewDelegate?
public override func viewDidLoad() {
super.viewDidLoad()
self.scrollView.delegate = delegate
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
struct ScrollableView_Previews: PreviewProvider {
static var previews: some View {
Wrapper()
}
struct Wrapper: View {
#State var offset: CGPoint = .init(x: 0, y: 50)
var body: some View {
HStack {
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
VStack {
Text("x: \(offset.x) y: \(offset.y)")
Button("Top", action: {
offset = .zero
})
.buttonStyle(.borderedProminent)
}
.frame(width: 200)
.padding()
}
}
}
}

SwiftUI: How to animate a TabView selection?

When tapping a TabView .tabItem in SwiftUI, the destination view associated with the .tabItem changes.
I tried around with putting
.animation(.easeInOut)
.transition(.slide)
as modifiers for the TabView, for the ForEach within, and for the .tabItem - but there is always a hard change of the destination views.
How can I animated that change, for instance, to slide in the selected view, or to cross dissolve it?
I checked Google, but found nothing about that problem...
for me, it works simply, it is a horizontal list, check //2 // 3
TabView(selection: $viewModel.selection.value) {
ForEach(viewModel.dates.indices) { index in
ZStack {
Color.white
horizontalListViewItem(item: viewModel.dates[index])
.tag(index)
}
}
}
.frame(width: UIScreen.main.bounds.width - 160, height: 80)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.animation(.easeInOut) // 2
.transition(.slide) // 3
Update Also Check Patrick's comment to this answer below :)
Since .animation is deprecated, use animation with equatable for same.
Try replacing :
.animation(.easeInOut)
.transition(.slide)
with :
#State var tabSelection = 0
// ...
.animation(.easeOut(duration: 0.2), value: tabSelection)
Demo
I have found TabView to be quite limited in terms of what you can do. Some limitations:
custom tab item
animations
So I set out to create a custom tab view. Here's using it with animation
Here's the usage of the custom tab view
struct ContentView: View {
var body: some View {
CustomTabView {
Text("Hello, World!")
.customTabItem {
Text("A")}
.customTag(0)
Text("Hola, mondo!")
.customTabItem { Text("B") }
.customTag(2)
}.animation(.easeInOut)
.transition(.slide)
}
}
Code
And here's the entirety of the custom tab view
typealias TabItem = (tag: Int, tab: AnyView)
class Model: ObservableObject {
#Published var landscape: Bool = false
init(isLandscape: Bool) {
self.landscape = isLandscape // Initial value
NotificationCenter.default.addObserver(self, selector: #selector(onViewWillTransition(notification:)), name: .my_onViewWillTransition, object: nil)
}
#objc func onViewWillTransition(notification: Notification) {
guard let size = notification.userInfo?["size"] as? CGSize else { return }
landscape = size.width > size.height
}
}
extension Notification.Name {
static let my_onViewWillTransition = Notification.Name("CustomUIHostingController_viewWillTransition")
}
class CustomUIHostingController<Content> : UIHostingController<Content> where Content : View {
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
NotificationCenter.default.post(name: .my_onViewWillTransition, object: nil, userInfo: ["size": size])
super.viewWillTransition(to: size, with: coordinator)
}
}
struct CustomTabView<Content>: View where Content: View {
#State private var currentIndex: Int = 0
#EnvironmentObject private var model: Model
let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
GeometryReader { geometry in
return ZStack {
// pages
// onAppear on all pages are called only on initial load
self.pagesInHStack(screenGeometry: geometry)
}
.overlayPreferenceValue(CustomTabItemPreferenceKey.self) { preferences in
// tab bar
return self.createTabBar(screenGeometry: geometry, tabItems: preferences.map {TabItem(tag: $0.tag, tab: $0.item)})
}
}
}
func getTabBarHeight(screenGeometry: GeometryProxy) -> CGFloat {
// https://medium.com/#hacknicity/ipad-navigation-bar-and-toolbar-height-changes-in-ios-12-91c5766809f4
// ipad 50
// iphone && portrait 49
// iphone && portrait && bottom safety 83
// iphone && landscape 32
// iphone && landscape && bottom safety 53
if UIDevice.current.userInterfaceIdiom == .pad {
return 50 + screenGeometry.safeAreaInsets.bottom
} else if UIDevice.current.userInterfaceIdiom == .phone {
if !model.landscape {
return 49 + screenGeometry.safeAreaInsets.bottom
} else {
return 32 + screenGeometry.safeAreaInsets.bottom
}
}
return 50
}
func pagesInHStack(screenGeometry: GeometryProxy) -> some View {
let tabBarHeight = getTabBarHeight(screenGeometry: screenGeometry)
let heightCut = tabBarHeight - screenGeometry.safeAreaInsets.bottom
let spacing: CGFloat = 100 // so pages don't overlap (in case of leading and trailing safetyInset), arbitrary
return HStack(spacing: spacing) {
self.content()
// reduced height, so items don't appear under tha tab bar
.frame(width: screenGeometry.size.width, height: screenGeometry.size.height - heightCut)
// move up to cover the reduced height
// 0.1 for iPhone X's nav bar color to extend to status bar
.offset(y: -heightCut/2 - 0.1)
}
.frame(width: screenGeometry.size.width, height: screenGeometry.size.height, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * screenGeometry.size.width + -CGFloat(self.currentIndex) * spacing)
}
func createTabBar(screenGeometry: GeometryProxy, tabItems: [TabItem]) -> some View {
let height = getTabBarHeight(screenGeometry: screenGeometry)
return VStack {
Spacer()
HStack(spacing: screenGeometry.size.width / (CGFloat(tabItems.count + 1) + 0.5)) {
Spacer()
ForEach(0..<tabItems.count, id: \.self) { i in
Group {
Button(action: {
self.currentIndex = i
}) {
tabItems[i].tab
}.foregroundColor(self.currentIndex == i ? .blue : .gray)
}
}
Spacer()
}
// move up from bottom safety inset
.padding(.bottom, screenGeometry.safeAreaInsets.bottom > 0 ? screenGeometry.safeAreaInsets.bottom - 5 : 0 )
.frame(width: screenGeometry.size.width, height: height)
.background(
self.getTabBarBackground(screenGeometry: screenGeometry)
)
}
// move down to cover bottom of new iphones and ipads
.offset(y: screenGeometry.safeAreaInsets.bottom)
}
func getTabBarBackground(screenGeometry: GeometryProxy) -> some View {
return GeometryReader { tabBarGeometry in
self.getBackgrounRectangle(tabBarGeometry: tabBarGeometry)
}
}
func getBackgrounRectangle(tabBarGeometry: GeometryProxy) -> some View {
return VStack {
Rectangle()
.fill(Color.white)
.opacity(0.8)
// border top
// https://www.reddit.com/r/SwiftUI/comments/dehx9t/how_to_add_border_only_to_bottom/
.padding(.top, 0.2)
.background(Color.gray)
.edgesIgnoringSafeArea([.leading, .trailing])
}
}
}
// MARK: - Tab Item Preference
struct CustomTabItemPreferenceData: Equatable {
var tag: Int
let item: AnyView
let stringDescribing: String // to let preference know when the tab item is changed
var badgeNumber: Int // to let preference know when the badgeNumber is changed
static func == (lhs: CustomTabItemPreferenceData, rhs: CustomTabItemPreferenceData) -> Bool {
lhs.tag == rhs.tag && lhs.stringDescribing == rhs.stringDescribing && lhs.badgeNumber == rhs.badgeNumber
}
}
struct CustomTabItemPreferenceKey: PreferenceKey {
typealias Value = [CustomTabItemPreferenceData]
static var defaultValue: [CustomTabItemPreferenceData] = []
static func reduce(value: inout [CustomTabItemPreferenceData], nextValue: () -> [CustomTabItemPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
// TabItem
extension View {
func customTabItem<Content>(#ViewBuilder content: #escaping () -> Content) -> some View where Content: View {
self.preference(key: CustomTabItemPreferenceKey.self, value: [
CustomTabItemPreferenceData(tag: 0, item: AnyView(content()), stringDescribing: String(describing: content()), badgeNumber: 0)
])
}
}
// Tag
extension View {
func customTag(_ tag: Int, badgeNumber: Int = 0) -> some View {
self.transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) in
guard value.count > 0 else { return }
value[0].tag = tag
value[0].badgeNumber = badgeNumber
}
.transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) -> Void in
guard value.count > 0 else { return }
value[0].tag = tag
value[0].badgeNumber = badgeNumber
}
.tag(tag)
}
}
And for the tab view to detect the phone's orientation, here's what you need to add to your SceneDelegate
if let windowScene = scene as? UIWindowScene {
let contentView = ContentView()
.environmentObject(Model(isLandscape: windowScene.interfaceOrientation.isLandscape))
let window = UIWindow(windowScene: windowScene)
window.rootViewController = CustomUIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
I was having this problem myself. There's actually a pretty simple solution. Typically we supply a selection parameter just by using the binding shorthand as $selectedTab. However if we create a Binding explicitly, then we'll have the chance to apply withAnimation closure when updating the value:
#State private var selectedTab = Tabs.firstTab
TabView(
selection: Binding<ModeSwitch.Value>(
get: {
selectedTab
},
set: { targetTab in
withAnimation {
selectedTab = targetTab
}
}
),
content: {
...
}
)
Now, there is a new idea.
swiftUI 2.0 xcode 12.2
tableView(){}.tabViewStyle(PageTabViewStyle())
😄

Detect touch outside Button in SwiftUI

I have a reset button that asks for confirmation first. I would like to set isSure to false is the user touches outside the component.
Can I do this from the Button component?
Here is my button:
struct ResetButton: View {
var onConfirmPress: () -> Void;
#State private var isSure: Bool = false;
var body: some View {
Button(action: {
if (self.isSure) {
self.onConfirmPress();
self.isSure.toggle();
} else {
self.isSure.toggle();
}
}) {
Text(self.isSure ? "Are you sure?" : "Reset")
}
}
}
here is one way to do it:
struct ContentView: View {
var onConfirmPress: () -> Void
#State private var isSure: Bool = false
var body: some View {
GeometryReader { geometry in
ZStack {
// a transparent rectangle under everything
Rectangle()
.frame(width: geometry.size.width, height: geometry.size.height)
.opacity(0.001) // <--- important
.layoutPriority(-1)
.onTapGesture {
self.isSure = false
print("---> onTapGesture self.isSure : \(self.isSure)")
}
Button(action: {
if (self.isSure) {
self.onConfirmPress()
}
self.isSure.toggle()
}) {
Text(self.isSure ? "Are you sure?" : "Reset").padding(10).border(Color.black)
}
}
}
}
}
Basically, we have some view, and we want a tap on its background to do something - meaning, we want to add a huge background that registers a tap. Note that .background is only offered the size of the main view, but can always set an explicit different size! If you know your size that's great, otherwise UIScreen could work...
This is hacky but seems to work!
extension View {
#ViewBuilder
private func onTapBackgroundContent(enabled: Bool, _ action: #escaping () -> Void) -> some View {
if enabled {
Color.clear
.frame(width: UIScreen.main.bounds.width * 2, height: UIScreen.main.bounds.height * 2)
.contentShape(Rectangle())
.onTapGesture(perform: action)
}
}
func onTapBackground(enabled: Bool, _ action: #escaping () -> Void) -> some View {
background(
onTapBackgroundContent(enabled: enabled, action)
)
}
}
Usage:
SomeView()
.onTapBackground(enabled: isShowingAlert) {
isShowingAlert = false
}
This can be easily changed to take a binding:
func onTapBackground(set value: Binding<Bool>) -> some View {
background(
onTapBackgroundContent(enabled: value.wrappedValue) { value.wrappedValue = false }
)
}
// later...
SomeView()
.onTapBackground(set: $isShowingAlert)

How to make the bottom button follow the keyboard display in SwiftUI

With the help of the following, I was able to follow the button on the keyboard display.
However, animation cannot be applied well.
How to show complete List when keyboard is showing up in SwiftUI
import SwiftUI
import Combine
import UIKit
class KeyboardResponder: ObservableObject {
let willset = PassthroughSubject<CGFloat, Never>()
private var _center: NotificationCenter
#Published var currentHeight: CGFloat = 0
var keyboardDuration: TimeInterval = 0
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
_center.removeObserver(self)
}
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
currentHeight = keyboardSize.height
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return }
keyboardDuration = duration
}
}
#objc func keyBoardWillHide(notification: Notification) {
currentHeight = 0
}
}
import SwiftUI
struct Content: View {
#ObservedObject var keyboard = KeyboardResponder()
var body: some View {
VStack {
Text("text")
Spacer()
NavigationLink(destination: SubContentView()) {
Text("Done")
}
}
.padding(.bottom, keyboard.currentHeight)
animation(Animation.easeInOut(duration: keyboard.keyboardDuration))
}
}
enter image description here
Your main problem, is that you are using an implicit animation. Not only it may be animating things you may not want to animate, but also, you should never apply .animation() on containers. Of the few warnings in SwiftUI's documentation, this is one of them:
Use this modifier on leaf views rather than container views. The
animation applies to all child views within this view; calling
animation(_:) on a container view can lead to unbounded scope.
Source: https://developer.apple.com/documentation/swiftui/view/3278508-animation
The modified code removes the implicit .animation() call and replaces it with two implicit withAnimation closures.
I also replaced keyboardFrameEndUserInfoKey with keyboardFrameEndUserInfoKey, second calls were giving useless geometry.
class KeyboardResponder: ObservableObject {
let willset = PassthroughSubject<CGFloat, Never>()
private var _center: NotificationCenter
#Published var currentHeight: CGFloat = 0
var keyboardDuration: TimeInterval = 0
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
_center.removeObserver(self)
}
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return }
keyboardDuration = duration
withAnimation(.easeInOut(duration: duration)) {
self.currentHeight = keyboardSize.height
}
}
}
#objc func keyBoardWillHide(notification: Notification) {
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return }
withAnimation(.easeInOut(duration: duration)) {
currentHeight = 0
}
}
}
struct ContentView: View {
#ObservedObject var keyboard = KeyboardResponder()
var body: some View {
return VStack {
Text("text \(keyboard.currentHeight)")
TextField("Enter text", text: .constant(""))
Spacer()
NavigationLink(destination: Text("SubContentView()")) {
Text("Done")
}
}
.padding(.bottom, keyboard.currentHeight)
// .animation(Animation.easeInOut(duration: keyboard.keyboardDuration))
}
}