xcodeswiftuigeometryreadermatchedgeometryeffect

Use .matchedGeometryEffect to move button from point A to point B SwiftUI?


I have question at the top of my view that looks something along the lines of "This is a _____ question"

and then I have choice buttons below the question

[choice1] [choice2] [choice3] [example]

Whenever the user clicks [example] I want it to move to the blank space in the question.

Are you able to help me implement .matchedGeometryEffect to achieve this?

Here is the main view, I am implementing WrappingHStack package

import SwiftUI
import WrappingHStack

struct MyTextPreferenceKey2: PreferenceKey {
    typealias Value = [MyTextPreferenceData2]
    
    static var defaultValue: [MyTextPreferenceData2] = []
    
    static func reduce(value: inout [MyTextPreferenceData2], nextValue: () -> [MyTextPreferenceData2]) {
        value.append(contentsOf: nextValue())
    }
}

struct MyTextPreferenceData2: Equatable {
    let viewIdx: Int
    let rect: CGRect
}

struct ShortStoryPlugInQuestionsView: View {
    @ObservedObject var shortStoryPlugInVM: ShortStoryViewModel
    
    @State private var activeIdx: Int = 0
    @State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 12)
    
    @State var characters: [pluginShortStoryCharacter] = [
        
        pluginShortStoryCharacter(value: "Lorem", isCorrect: false),
        pluginShortStoryCharacter(value: "nasce", isCorrect: true),
        pluginShortStoryCharacter(value: "is", isCorrect: false),
        pluginShortStoryCharacter(value: "simply", isCorrect: false),
        pluginShortStoryCharacter(value: "dummy", isCorrect: false),
        pluginShortStoryCharacter(value: "text", isCorrect: false),
        pluginShortStoryCharacter(value: "if", isCorrect: false),
        pluginShortStoryCharacter(value: "the", isCorrect: false),
        pluginShortStoryCharacter(value: "design", isCorrect: false),

    ]
    
    //for drag
    @State var shuffledRows: [[pluginShortStoryCharacter]] = []
    //for drop
    @State var rows: [[pluginShortStoryCharacter]] = []
    
    @State private var frames: [CGRect] = [CGRect]()
    
    @State private var correctAnswers: [String] = ["nasce"]
    @State private var animateCorrect = false
    
    @Namespace private var namespace
    
    var body: some View {
            

        GeometryReader { geo in
            ZStack{
                VStack(spacing: 0){
                    ScrollViewReader {scrollView in
                        ScrollView(.horizontal){
                            HStack{
                                ForEach(0..<shortStoryPlugInVM.currentPlugInQuestions.count, id: \.self) {i in
                                    VStack{
                                        VStack{
                                            var sentenceArray: [String] = shortStoryPlugInVM.currentPlugInQuestions[i].question.components(separatedBy: " ")
                                            
                                            var missingWord: String = shortStoryPlugInVM.currentPlugInQuestions[i].missingWord
                                            
                                            WrappingHStack(0..<sentenceArray.count, id:\.self) {i in
                                                
                                                
                                                if sentenceArray[i].elementsEqual(missingWord) {
                                                    if animateCorrect{
                                                        Text("nasce")
                                                            .background{
                                                                RoundedRectangle(cornerRadius: 6, style: .continuous)
                                                                    .stroke(.gray)
                                                            }
                                                            .matchedGeometryEffect(id: "rightAnswer", in: namespace)
          
                                                    }else{
                                                        RoundedRectangle(cornerRadius: 10.0)
                                                            .fill(Color.teal)
                                                            .frame(width: 35, height: 10)
                                                        
                                                    }
                                                }else {
                                                    Text(String(sentenceArray[i]))
                                                        .padding(3)
                                                }
                                            }.frame(minWidth: 250)
                                            
                                                
                                            //correctAnswers.append(shortStoryPlugInVM.currentPlugInQuestions[i].missingWord)
      
                    
                                                
                                        
                                        }.frame(width: 300, height: 200)
                                            .background(.teal)
                                        
                                        DragArea()
                                          
             
                                    }.frame(width: geo.size.width)
                                    .frame(minHeight: geo.size.height)
                                }
                            }
                            
                        }
                        
                    }
                    
                
                }.onPreferenceChange(MyTextPreferenceKey2.self) { preferences in
                    for p in preferences {
                        self.rects[p.viewIdx] = p.rect
                    }
                }
                    
  
            }.onAppear{
                shortStoryPlugInVM.setShortStoryData(storyName: "Cristofo Columbo")
                if rows.isEmpty{
                    //First Creating shuffled On
                    //then normal one
                    characters = characters.shuffled()
                    rows = generateGrid()
                    shuffledRows = generateGrid()
                    rows = generateGrid()
                }
        
            }
            .coordinateSpace(name: "myZstack")
        }
    }
    
    func setFrame(index: Int, frame: CGRect) {
        self.frames.append(frame)
    }
    
    func combineTextObjects(_ objects: [Text]) -> Text{
        return objects[1...].reduce(objects[0], +)
    }
    
    @ViewBuilder
    func DragArea()->some View {
        VStack(spacing: 12){
            ForEach(shuffledRows, id: \.self){row in
                HStack(spacing:10){
                    ForEach(row){item in
                            Text(item.value)
                                .font(.system(size: item.fontSize))
                                .padding(.vertical, 5)
                                .padding(.horizontal, item.padding)
                                .background{
                                    RoundedRectangle(cornerRadius: 6, style: .continuous)
                                        .stroke(.gray)
                                }
                                .opacity(item.isShowing ? 0 : 1)
                                .background{
                                    RoundedRectangle(cornerRadius: 6, style: .continuous)
                                        .fill(item.isShowing ? .gray.opacity(0.25) : .clear)
                                }
                            //                            .offset(x: animateCorrect && item.isCorrect ? rects[0].minX : 0, y: animateCorrect && item.isCorrect ? rects[0].minY : 0)
                                .onTapGesture{
                                    if item.isCorrect {
                                        SoundManager.instance.playSound(sound: .correct)
                                        
                                        withAnimation(.easeIn){
                                            animateCorrect = true
                                        }
                                    }else{
                                        SoundManager.instance.playSound(sound: .wrong)
                                    }
                                }
                               // .matchedGeometryEffect(id: "rightAnswer", in: namespace)
                        
                    }
                }
                
            }
        }
    }
    
    func generateGrid()->[[pluginShortStoryCharacter]]{
        for item in characters.enumerated() {
            let textSize = textSize(character: item.element)
            
            characters[item.offset].textSize = textSize
            
        }
        
        var gridArray: [[pluginShortStoryCharacter]] = []
        var tempArray: [pluginShortStoryCharacter] = []
        
        var currentWidth: CGFloat = 0
        
        let totalScreenWidth: CGFloat = UIScreen.main.bounds.width - 30
        
        for character in characters {
            currentWidth += character.textSize
            
            if currentWidth < totalScreenWidth{
                tempArray.append(character)
            }else {
                gridArray.append(tempArray)
                tempArray = []
                currentWidth = character.textSize
                tempArray.append(character)
            }
        }
        
        if !tempArray.isEmpty{
            gridArray.append(tempArray)
        }
        
        return gridArray
    }
    
    func textSize(character: pluginShortStoryCharacter)->CGFloat{
        let font = UIFont.systemFont(ofSize: character.fontSize)
        
        let attributes = [NSAttributedString.Key.font : font]
        
        let size = (character.value as NSString).size(withAttributes: attributes)
        
        return size.width + (character.padding * 2) + 15
    }
    
    func updateShuffledArray(character: pluginShortStoryCharacter){
        for index in shuffledRows.indices{
            for subIndex in shuffledRows[index].indices{
                if shuffledRows[index][subIndex].id == character.id{
                    shuffledRows[index][subIndex].isShowing = true
                }
            }
        }
    }
}

struct MyPreferenceViewSetter2: View {
    let idx: Int
    
    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(Color.white)
                .preference(key: MyTextPreferenceKey2.self,
                            value: [MyTextPreferenceData2(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))])
        }
    }
}


struct pluginShortStoryCharacter: Identifiable, Hashable, Equatable {
    var id = UUID().uuidString
    var value: String
    var isCorrect: Bool
    var padding: CGFloat = 10
    var textSize: CGFloat = .zero
    var fontSize: CGFloat = 19
    var isShowing: Bool = false

}


struct ShortStoryPlugInQuestionsView_Previews: PreviewProvider {
    static var shortStoryPlugInVM = ShortStoryViewModel(currentStoryIn: 0)
    static var previews: some View {
        ShortStoryPlugInQuestionsView(shortStoryPlugInVM: shortStoryPlugInVM)
    }
}

Here is the view model

import Foundation

final class ShortStoryViewModel: ObservableObject {
    @Published private(set) var currentPlugInStoryData: [shortStoryPlugInDataObj] = [shortStoryPlugInDataObj]()
    @Published private(set) var currentPlugInQuestions: [FillInBlankQuestion] = [FillInBlankQuestion]()
    @Published private(set) var currentStory: String
    
    init(currentStoryIn: Int){
        switch currentStoryIn {
        case 0:
            currentStory = "Cristofo Columbo"
        default:
            currentStory = "Cristofo Columbo"
        }
    }
    

    
    func setShortStoryData(storyName: String) {
        
        var tempArray: [shortStoryPlugInDataObj] = [shortStoryPlugInDataObj]()
        
        let shortStoryList: [storyObject] = storyObject.allStoryObjects
        
        var chosenStoryObject: storyObject = shortStoryList[0]
        
        let storyString = chosenStoryObject.story

        let wordLinks: [WordLink] = chosenStoryObject.wordLinks

        let questions: [QuestionsObj] = chosenStoryObject.questionsObjs
        
        let plugInQuestions: [FillInBlankQuestion] = chosenStoryObject.fillInBlankQuestions
        
        currentPlugInQuestions = plugInQuestions
        
        var newObj = shortStoryPlugInDataObj(storyString: storyString, wordLinksArray: wordLinks, questionList: questions, plugInQuestionlist: plugInQuestions)
        
        tempArray.append(newObj)
        
        currentPlugInStoryData = tempArray
        
    }

JSON Manager with object structs

 import Foundation

struct verbObject: Codable{
    var verb: Verb
    var presenteConjList, passatoProssimoConjList, futuroConjList, imperfettoConjList: [String]
    var presenteCondizionaleConjList, imperativoConjList: [String]
    
    static let allVerbObject: [verbObject] = Bundle.main.decode(file: "ItalianAppVerbData.json")
    
}
    

struct Verb: Codable {
    var verbName, verbEngl: String
}


struct storyObject: Codable {
    let storyName, story: String
    let wordLinks: [WordLink]
    let questionsObjs: [QuestionsObj]
    let fillInBlankQuestions: [FillInBlankQuestion]
    var dragAndDropQuestions: [DragAndDropQuestion]
    
    static let allStoryObjects: [storyObject] = Bundle.main.decode(file: "shortStoryAppData.json")
    static let columbo: storyObject = allStoryObjects[0]
}

// MARK: - DragAndDropQuestion
struct DragAndDropQuestion: Codable {
    var englishSentence: String
    var choices: [String]
}



struct FillInBlankQuestion: Codable {
    let englishLine1, question, missingWord: String
    let choices: [String]
}








extension Bundle {
    func decode<T: Decodable>(file: String) -> T {
        guard let url = self.url(forResource: file, withExtension: nil) else {
            fatalError("Could not find \(file) in the project!")
        }
        
        guard let data = try? Data(contentsOf: url) else {
            fatalError("Could not load \(file) in the project!")
        }
        
        let decoder = JSONDecoder()
        
        guard let loadedData = try? decoder.decode(T.self, from: data) else {
            fatalError("Culd not decode \(file) in the project!")
        }
        
        return loadedData
    }
}

Solution

  • Using matchedGeometryEffect is all about matching up ids in a namespace and identifying one source as the target for the view position.

    In your case, I think you start off with a blank placeholder for the answer to a question and you want to replace this with the true answer. Instead of trying to pick through your code, I have tried to prepare a working example that you can maybe use to understand the mechanisms. Hope it helps.

    struct ContentView: View {
    
        @Namespace private var namespace
        @State private var fillingBlank = false
        @State private var answer = 0
    
        private func buttonForAnswer(num: Int) -> some View {
            Button("Answer \(num)") {
                answer = num
                withAnimation {
                    fillingBlank = true
                }
            }
            .buttonStyle(.borderedProminent)
            .matchedGeometryEffect(
                id: num,
                in: namespace,
                isSource: answer == num && !fillingBlank
            )
            .background {
    
                // This is the text that floats to the blank space
                Text("Answer \(num)")
                    .foregroundColor(.primary)
                    .matchedGeometryEffect(
                        id: answer == num && fillingBlank ? 0 : num,
                        in: namespace,
                        properties: .position,
                        isSource: false
                    )
            }
        }
    
        var body: some View {
            VStack(spacing: 30) {
    
                // Question section
                HStack {
                    Text("(question part 1)")
                    Text("blank space")
                        .foregroundColor(.secondary.opacity(0.5))
                        .opacity(fillingBlank ? 0 : 1)
                        .background(alignment: .bottom) {
                            VStack {
                                Divider().background(.primary)
                            }
                        }
                        .matchedGeometryEffect(
                            id: 0,
                            in: namespace,
                            isSource: fillingBlank
                        )
                    Text("(question part 2)")
                }
                // Answer section
                Text("Every answer is correct, please pick one!")
                    .padding(.top, 50)
                VStack {
                    HStack(spacing: 20) {
    
                        // The buttons for the answers
                        ForEach(1...3, id: \.self) { num in
                            buttonForAnswer(num: num)
                        }
                    }
                    .overlay {
    
                        // The reset button
                        if fillingBlank {
                            HStack {
                                Button("Reset") {
                                    withAnimation {
                                        fillingBlank = false
                                    }
                                }
                                .buttonStyle(.borderedProminent )
                                .tint(.orange)
                            }
                            .frame(maxWidth: .infinity)
                            .background(Color(UIColor.systemBackground))
                        }
                    }
                }
            }
        }
    }
    

    Animation