Scroll SwiftUI List to new selection - macos

If you have a SwiftUI List with that allows single selection, you can change the selection by clicking the list (presumably this makes it the key responder) and then using the arrow keys. If that selection reaches the end of the visible area, it will scroll the whole list to keep the selection visible.
However, if the selection object is updated in some other way (e.g. using a button), the list will not be scrolled.
Is there any way to force the list to scroll to the new selection when set programmatically?
Example app:
import SwiftUI
struct ContentView: View {
#State var selection: Int? = 0
func changeSelection(_ by: Int) {
switch self.selection {
case .none:
self.selection = 0
case .some(let sel):
self.selection = max(min(sel + by, 20), 0)
}
}
var body: some View {
HStack {
List((0...20), selection: $selection) {
Text(String($0))
}
VStack {
Button(action: { self.changeSelection(-1) }) {
Text("Move Up")
}
Button(action: { self.changeSelection(1) }) {
Text("Move Down")
}
}
}
}
}

I tried several solutions, one of them I'm using in my project (I need horizontal paging for 3 lists). And here are my observations:
I didn't find any methods to scroll List in SwiftUI, there is no mention about it in documentation yet;
You may try ScrollView (my variant below, here is other solution), but these things might look monstroid;
Maybe the best way is to use UITableView: tutorial from Apple and try scrollToRowAtIndexPath method (like in this answer).
As I wrote, here is my example, which, of course, requires refinement. First of all ScrollView needs to be inside GeometryReader and you can understand the real size of content. The second thing is that you need to control your gestures, which might be difficult. And the last one: you need to calculate current offset of ScrollViews's content and it could be other than in my code (remember, I tried to give you example):
struct ScrollListView: View {
#State var selection: Int?
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
func changeSelection(_ by: Int) {
switch self.selection {
case .none:
self.selection = 0
case .some(let sel):
self.selection = max(min(sel + by, 30), 0)
}
}
var body: some View {
HStack {
GeometryReader { geometry in
VStack {
ScrollView(.vertical, showsIndicators: false) {
ForEach(0...29, id: \.self) { line in
ListRow(line: line, selection: self.$selection)
.frame(height: 20)
}
}
.content.offset(y: self.isGestureActive ? self.offset : geometry.size.height / 4 - CGFloat((self.selection ?? 0) * 20))
.gesture(DragGesture()
.onChanged({ value in
self.isGestureActive = true
self.offset = value.translation.width + -geometry.size.width * CGFloat(self.selection ?? 1)
})
.onEnded({ value in
DispatchQueue.main.async { self.isGestureActive = false }
}))
}
}
VStack {
Button(action: { self.changeSelection(-1) }) {
Text("Move Up")
}
Spacer()
Button(action: { self.changeSelection(1) }) {
Text("Move Down")
}
}
}
}
}
of course you need to create your own "list row":
struct ListRow: View {
#State var line: Int
#Binding var selection: Int?
var body: some View {
HStack(alignment: .center, spacing: 2){
Image(systemName: line == self.selection ? "checkmark.square" : "square")
.padding(.horizontal, 3)
Text(String(line))
Spacer()
}
.onTapGesture {
self.selection = self.selection == self.line ? nil : self.line
}
}
}
hope it'll be helpful.

In the new relase of SwiftUI for iOs 14 and MacOs Big Sur they added the ability to programmatically scroll to a specific cell using the new ScrollViewReader:
struct ContentView: View {
let colors: [Color] = [.red, .green, .blue]
var body: some View {
ScrollView {
ScrollViewReader { value in
Button("Jump to #8") {
value.scrollTo(8)
}
ForEach(0..<10) { i in
Text("Example \(i)")
.frame(width: 300, height: 300)
.background(colors[i % colors.count])
.id(i)
}
}
}
}
}
Then you can use the method .scrollTo() like this
value.scrollTo(8, anchor: .top)
Credit: www.hackingwithswift.com

I am doing it this way:
1) Reusable copy-paste component:
import SwiftUI
struct TableViewConfigurator: UIViewControllerRepresentable {
var configure: (UITableView) -> Void = { _ in }
func makeUIViewController(context: UIViewControllerRepresentableContext<TableViewConfigurator>) -> UIViewController {
UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<TableViewConfigurator>) {
//let tableViews = uiViewController.navigationController?.topViewController?.view.subviews(ofType: UITableView.self) ?? [UITableView]()
let tableViews = UIApplication.nonModalTopViewController()?.navigationController?.topViewController?.view.subviews(ofType: UITableView.self) ?? [UITableView]()
for tableView in tableViews {
self.configure(tableView)
}
}
}
2) Extension on UIApplication to find top view controller in hierarchy
extension UIApplication {
class var activeSceneRootViewController: UIViewController? {
if #available(iOS 13.0, *) {
for scene in UIApplication.shared.connectedScenes {
if scene.activationState == .foregroundActive {
return ((scene as? UIWindowScene)?.delegate as? UIWindowSceneDelegate)?.window??.rootViewController
}
}
} else {
// Fallback on earlier versions
return UIApplication.shared.keyWindow?.rootViewController
}
return nil
}
class func nonModalTopViewController(controller: UIViewController? = UIApplication.activeSceneRootViewController) -> UIViewController? {
print(controller ?? "nil")
if let navigationController = controller as? UINavigationController {
return nonModalTopViewController(controller: navigationController.topViewController ?? navigationController.visibleViewController)
}
if let tabController = controller as? UITabBarController {
if let selected = tabController.selectedViewController {
return nonModalTopViewController(controller: selected)
}
}
if let presented = controller?.presentedViewController {
let top = nonModalTopViewController(controller: presented)
if top == presented { // just modal
return controller
} else {
print("Top:", top ?? "nil")
return top
}
}
if let navigationController = controller?.children.first as? UINavigationController {
return nonModalTopViewController(controller: navigationController.topViewController ?? navigationController.visibleViewController)
}
return controller
}
}
3) Custom part - Here you implement your solution for UITableView behind List like scrolling:
Use it like modifier on any view in List in View
.background(TableViewConfigurator(configure: { tableView in
if self.viewModel.statusChangeMessage != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
let lastIndexPath = IndexPath(row: tableView.numberOfRows(inSection: 0) - 1, section: 0)
tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: true)
}
}
}))

Related

How to detect a single mouse down event in SwiftUI on macOS

I want to detect ONE mouse down and ONE mouse up event on my view (a simple Rectangle). Here is the code I already made. Unfortunately I got a lot of 'mouse down' and 'mouse up' on the console. This not what I want. I want just one 'mouse down' when the mouse is pressed on my rectangle and one 'mouse up' when the mouse is released.
var body: some View {
Rectangle()
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { event in
print ("mouse down")
return event
}
NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { event in
print ("mouse up")
return event
}
})
}
I found the solution by using a View modifier
//
// ContentView.swift
// OnPressedOnRelease
//
import SwiftUI
struct ContentView: View {
#State private var message: String = "Click on rectangle"
var body: some View {
VStack {
Image(systemName: "magicmouse")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("\(message)")
Rectangle()
.modifier(PressActions(
onPress: {
// Do something on press...
message = "Mouse down"
},
onRelease: {
// Do something on release...
message = "Mouse up"
}
))
}
.padding()
.frame(width: 200, height: 200)
}
}
struct PressActions: ViewModifier {
var onPress: () -> Void
var onRelease: () -> Void
func body(content: Content) -> some View {
content
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged({ _ in
onPress()
})
.onEnded({ _ in
onRelease()
})
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I've recently worked with Gestures and a good solution is to do this :
struct ContentView: View {
#State var dragGestureValue: DragGesture.Value?
var body: some View {
Rectangle()
.gesture(pressActionsGesture())
.padding()
}
private func pressActionsGesture() -> some Gesture {
return DragGesture(minimumDistance: 0)
.onChanged {
// If nil, it means its the beginning of the DragGesture
if dragGestureValue == nil {
print("Mouse Down")
}
// Gives DragGesture informations to our #State value
dragGestureValue = $0
}
.onEnded { _ in
print("Mouse Up")
dragGestureValue = nil
}
}
}

Show a sheet in response to a drop

I'm implementing drag and drop, and have a case where I need the user to decide what to do in response to a drop. So I want to bring up a sheet to ask the user for input. The problem is that the sheet doesn't appear until I drag another item to the same view. This does make sense, so I'm looking for a way to handle this differently.
The current approach looks like this (simplified):
struct SymbolInfo {
enum SymbolType {
case string, systemName
}
var type: SymbolType
var string: String
}
struct MyView: View, DropDelegate {
#State var sheetPresented = false
#State var droppedText = ""
static let dropTypes = [UTType.utf8PlainText]
var textColor = NSColor.white
private var frameRect: CGRect = .null
private var contentPath: Path = Path()
private var textRect: CGRect = .null
#State private var displayOutput: SymbolInfo
#State private var editPopoverIsPresented = false
// There's an init to set up the display output, the various rects and path
var body: some View {
ZStack(alignment: stackAlignment) {
BackgroundView() // Draws an appropriate background
.frame(width: frameRect.width, height: frameRect.height)
if displayOutput.type == .string {
Text(displayOutput.string)
.frame(width: textRect.width, height: textRect.height, alignment: .center)
.foregroundColor(textColour)
.font(displayFont)
.allowsTightening(true)
.lineLimit(2)
.minimumScaleFactor(0.5)
}
else {
Image(systemName: displayOutput.string)
.frame(width: textRect.width, height: textRect.height, alignment: .center)
.foregroundColor(textColour)
.minimumScaleFactor(0.5)
}
}
.onAppear {
// Retrieve state information from the environment
}
.focusable(false)
.allowsHitTesting(true)
.contentShape(contentPath)
.onHover { entered in
// Populates an inspector
}
.onTapGesture(count: 2) {
// Handle a double click
}
.onTapGesture(count: 1) {
// Handle a single click
}
.popover(isPresented: $editPopoverIsPresented) {
// Handles a popover for editing data
}
.onDrop(of: dropTypes, delegate: self)
.sheet(sheetPresented: $sheetPresented, onDismiss: sheetReturn) {
// sheet to ask for the user's input
}
}
func sheetReturn() {
// act on the user's input
}
func performDrop(info: DropInfo) -> Bool {
if let item = info.itemProviders(for: dropTypes).first {
item.loadItem(forTypeIdentifier: UTType.utf8PlainText.identifier, options: nil) { (textData, error) in
if let textData = String(data: textData as! Data, encoding: .utf8) {
if (my condition) {
sheetIsPresented = true
droppedText = textData
}
else {
// handle regular drop
}
}
}
return true
}
return false
}
}
So my reasoning is that the drop sets sheetPresented to true, but then it doesn't get acted on until the view is rebuilt, such as on dragging something else to it. But I'm still new to SwiftUI, so I may be incorrect.
Is there a way to handle this kind of interaction that I haven't found?
I never was able to exactly reproduce the problem, but the issue related to trying to have more than one kind of sheet that could be shown, depending on conditions. The solution was to break up the original view into a family of views that encapsulated the different behaviours, and show the appropriate one rather than try to make one view do everything.
I won't show the whole code, since it's too deeply embedded in the app, but here's a demo app that works correctly:
import SwiftUI
import UniformTypeIdentifiers
#main
struct DragAndDropSheetApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
HStack() {
TargetView(viewType: .normal, viewText: "A")
.frame(width: 40, height: 40, alignment: .top)
TargetView(viewType: .protected, viewText: "B")
.frame(width: 40, height: 40, alignment: .top)
TargetView(viewType: .normal, viewText: "C")
.frame(width: 40, height: 40, alignment: .top)
TargetView(viewType: .protected, viewText: "D")
.frame(width: 40, height: 40, alignment: .top)
}
.padding()
}
}
enum ViewType {
case normal, protected
}
struct TargetView: View, DropDelegate {
#State private var sheetPresented = false
#State var viewType: ViewType
#State var viewText: String
#State private var dropText = ""
#State private var dropType: DropActions = .none
static let dropTypes = [UTType.utf8PlainText]
var body: some View {
ZStack(alignment: .center) {
Rectangle()
.foregroundColor(viewType == .normal ? .blue : .red)
Text(viewText)
.foregroundColor(.white)
.frame(width: nil, height: nil, alignment: .center)
}
.focusable(false)
.allowsHitTesting(true)
.onDrop(of: TargetView.dropTypes, delegate: self)
.sheet(isPresented: $sheetPresented, onDismiss: handleSheetReturn) {
ProtectedDrop(isPresented: $sheetPresented, action: $dropType)
}
}
func handleSheetReturn() {
switch dropType {
case .append:
viewText += dropText
case .replace:
viewText = dropText
case .none:
// Nothing to do
return
}
}
func performDrop(info: DropInfo) -> Bool {
if let item = info.itemProviders(for: TargetView.dropTypes).first {
item.loadItem(forTypeIdentifier: UTType.utf8PlainText.identifier, options: nil) { textData, error in
if let textData = String(data: textData as! Data, encoding: .utf8) {
if viewType == .normal {
viewText = textData
}
else {
dropText = textData
sheetPresented = true
}
}
}
return true
}
return false
}
}
enum DropActions: Hashable {
case append, replace, none
}
struct ProtectedDrop: View {
#Binding var isPresented: Bool
#Binding var action: DropActions
var body: some View {
VStack() {
Text("This view is protected. What do you want to do?")
Picker("", selection: $action) {
Text("Append the dropped text")
.tag(DropActions.append)
Text("Replace the text")
.tag(DropActions.replace)
}
.pickerStyle(.radioGroup)
HStack() {
Spacer()
Button("Cancel") {
action = .none
isPresented.toggle()
}
.keyboardShortcut(.cancelAction)
Button("OK") {
isPresented.toggle()
}
.keyboardShortcut(.defaultAction)
}
}
.padding()
}
}

swiftui, animation applied to parent effect child animation

RectangleView has a slide animation, his child TextView has a rotation animation. I suppose that RectangleView with his child(TextView) as a whole slide(easeInOut) into screen when Go! pressed, and TextView rotate(linear) forever. But in fact, the child TextView separates from his parent, rotating(linear) and sliding(linear), and repeats forever.
why animation applied to parent effect child animation?
struct AnimationTestView: View {
#State private var go = false
var body: some View {
VStack {
Button("Go!") {
go.toggle()
}
if go {
RectangleView()
.transition(.slide)
.animation(.easeInOut)
}
}.navigationTitle("Animation Test")
}
}
struct RectangleView: View {
var body: some View {
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.pink)
.overlay(TextView())
}
}
struct TextView: View {
#State private var animationRotating: Bool = false
let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)
var body: some View {
Text("Test")
.foregroundColor(.blue)
.rotationEffect(.degrees(animationRotating ? 360 : 0))
.animation(animation)
.onAppear { animationRotating = true }
.onDisappear { animationRotating = false }
}
}
If there are several simultaneous animations the generic solution (in majority of cases) is to use explicit state value for each.
So here is a corrected code (tested with Xcode 12.1 / iOS 14.1, use Simulator or Device, Preview renders some transitions incorrectly)
struct AnimationTestView: View {
#State private var go = false
var body: some View {
VStack {
Button("Go!") {
go.toggle()
}
VStack { // container needed for correct transition !!
if go {
RectangleView()
.transition(.slide)
}
}.animation(.easeInOut, value: go) // << here !!
}.navigationTitle("Animation Test")
}
}
struct RectangleView: View {
var body: some View {
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.pink)
.overlay(TextView())
}
}
struct TextView: View {
#State private var animationRotating: Bool = false
let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)
var body: some View {
Text("Test")
.foregroundColor(.blue)
.rotationEffect(.degrees(animationRotating ? 360 : 0))
.animation(animation, value: animationRotating) // << here !!
.onAppear { animationRotating = true }
.onDisappear { animationRotating = false }
}
}

SwiftUI URLImage Horizontal ScrollView

So I've been going through a SwiftUI instagram tutorial and learnt how to load images uploaded by user to firebase in the standard 3x3 instagram view but am now wanting to expand my knowledge and practice doing it in horizontal scrollview.
Here's what I have to create grid view:
import SwiftUI
import URLImage
import FirebaseAuth
struct Photo: Identifiable {
let id = UUID()
var photo = ""
}
struct PhotoView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
#EnvironmentObject var session: SessionStore
#ObservedObject var profileViewModel = ProfileViewModel()
var body: some View {
return
ScrollView {
if !profileViewModel.isLoading {
VStack(alignment: .leading, spacing: 1) {
// rows
ForEach(0..<self.profileViewModel.splitted.count) { index in
HStack(spacing: 1) {
// Columns
ForEach(self.profileViewModel.splitted[index], id: \.postId) { post in
URLImage(URL(string: post.mediaUrl)!,
content: {
$0.image
.resizable()
.aspectRatio(contentMode: .fill)
}).frame(width: UIScreen.main.bounds.width / 3, height: UIScreen.main.bounds.width / 3).clipped().cornerRadius(5)
}
}
}
}.frame(width: UIScreen.main.bounds.width, alignment: .leading).padding(.top, 2)
}
}.navigationBarTitle(Text("Photos"), displayMode: .inline).navigationBarBackButtonHidden(true).navigationBarItems(leading: Button(action : {
self.mode.wrappedValue.dismiss()
}) {
Image(systemName: "arrow.left")
}).onAppear {
self.profileViewModel.loadUserPosts(userId: Auth.auth().currentUser!.uid)
}
}
}
extension Array {
func splitted(into size:Int) -> [[Element]] {
var splittedArray = [[Element]]()
if self.count >= size {
for index in 0...self.count {
if index % size == 0 && index != 0 {
splittedArray.append(Array(self[(index - size)..<index]))
} else if (index == self.count) {
splittedArray.append(Array(self[index - 1..<index]))
}
}
} else {
splittedArray.append(Array(self[0..<self.count]))
}
return splittedArray
}
}
class ProfileViewModel: ObservableObject {
#Published var posts: [Post] = []
#Published var isLoading = false
var splitted: [[Post]] = []
func loadUserPosts(userId: String) {
isLoading = true
Api.User.loadPosts(userId: userId) { (posts) in
self.isLoading = false
self.posts = posts
self.splitted = self.posts.splitted(into: 3)
}
}
}
And this is what it looks like:
This is the sample code for what I am trying to achieve:
import SwiftUI
import URLImage
import FirebaseAuth
struct TestView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var body: some View {
VStack {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(1..<5) { _ in
Image("photo3").resizable()
.clipShape(Rectangle())
.aspectRatio(contentMode: ContentMode.fill)
.frame(width: 100, height: 100).cornerRadius(10).opacity(1).shadow(radius: 4)
}
}
}.navigationBarTitle(Text("Photos"), displayMode: .inline).navigationBarBackButtonHidden(true).navigationBarItems(leading: Button(action : {
self.mode.wrappedValue.dismiss()
}) {
Image(systemName: "arrow.left")
})
Spacer()
}.padding()
}
}
and here is the sample image of what I want it to look like:
I'm really struggling to understand the ForLoop part and how I can retrieve the image to just be in a simple scrollView.
Any help would be much appreciated!
Thanks!
You want to loop over the posts in your model. Borrowing from your earlier code, you need something like this:
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(self.profileViewModel.posts, id: \.postId) { post in
URLImage(URL(string: post.mediaUrl)!,
content: {
$0.image
.resizable()
.aspectRatio(contentMode: .fill)
}
)
.frame(width: 100, height: 100)
.clipped()
.cornerRadius(10)
.shadow(radius: 4)
}
}
}
vacawama has already posted the perfect solution to make it look like your example.
Just to add why you achieve the result, you are getting.
The difference between your code and the sample code is that you are using two ForEach, one for the rows and one for the columns. The array gets splitted with your extension, so you get rows and columns.
//Rows
ForEach(0..<self.profileViewModel.splitted.count) { index in
HStack(spacing: 1) {
// Columns
ForEach(self.profileViewModel.splitted[index], id: \.postId) { post in
Your comments already stating how it works. If you want to have all your images in a horizontal scroller, you just need one ForEach which outputs all your images in a ScrollView.
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(self.profileViewModel.posts, id: \.postId) { post in

How to display an image where the user taps? SwiftUI

I am making a mining tapping game and I want to display a hammer wherever the user taps.
I mean, wherever the user taps the hammer image will stay on for one second.
Is there a way to do it?
My example code is below:
struct Level1: View {
#State var tapScore = 0
#State var showingMinedHammer = false
func showMinedHammer() {
self.showingMinedHammer = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.showingMinedHammer = false
}
}
func mine() {
tapScore += 1
showMinedHammer()
}
var body: some View {
GeometryReader { geometryProxy in
ZStack {
Image("mine1").resizable().frame(width: UIScreen.main.bounds.height * 1.4, height: UIScreen.main.bounds.height)
.onTapGesture {
self.mine()
}
if self.showingMinedHammer {
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
}
}
}.edgesIgnoringSafeArea(.all)
}
}
It just need to read location of tap and use it as position for hammer image, like below - all by SwiftUI
Tested with Xcode 11.4 / iOS 13.4
Here is modified only part
#State private var location = CGPoint.zero // < here !!
var body: some View {
GeometryReader { geometryProxy in
ZStack {
Image("mine1").resizable().frame(width: UIScreen.main.bounds.height * 1.4, height: UIScreen.main.bounds.height)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.location = value.location // < here !!
self.mine()
})
if self.showingMinedHammer {
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
.position(self.location) // < here !!
}
}
}.edgesIgnoringSafeArea(.all)
}
To get the location of where you tapped, you can do something like this:
import SwiftUI
struct ContentView: View {
#State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
var body: some View {
ZStack{
GetTapLocation {
// tappedCallback
location in
self.points.append(location)
print(self.points)
}
}
}
}
struct GetTapLocation:UIViewRepresentable {
var tappedCallback: ((CGPoint) -> Void)
func makeUIView(context: UIViewRepresentableContext<GetTapLocation>) -> UIView {
let v = UIView(frame: .zero)
let gesture = UITapGestureRecognizer(target: context.coordinator,
action: #selector(Coordinator.tapped))
v.addGestureRecognizer(gesture)
return v
}
class Coordinator: NSObject {
var tappedCallback: ((CGPoint) -> Void)
init(tappedCallback: #escaping ((CGPoint) -> Void)) {
self.tappedCallback = tappedCallback
}
#objc func tapped(gesture:UITapGestureRecognizer) {
let point = gesture.location(in: gesture.view)
self.tappedCallback(point)
}
}
func makeCoordinator() -> GetTapLocation.Coordinator {
return Coordinator(tappedCallback:self.tappedCallback)
}
func updateUIView(_ uiView: UIView,
context: UIViewRepresentableContext<GetTapLocation>) {
}
}
There has to be a simpler implementation, but until then you can get the location where you tapped. I hope that helps :)

Resources