Connect Letters inside Circle Container in Flutter

Connect Letters inside Circle Container in Flutter

Realm Flutter

9 min read

Greetings to the amazing Flutter community! Have you ever played the famous game Word Of Wonders and found yourself wondering: How could I implement the feature where you connect all the letters inside the circle? How do you place all those letters inside the circle, and how do you create a word with its values? Well, I recently embarked on a mission to solve this puzzle, and let me tell you, it's been quite a challenge to find resources on the web. It's one of those undocumented features that seem like hidden gems waiting to be unearthed. Today, I'm very excited to share such a discovery with you, and the best part is that we're diving straight into the code, no packages, no setup needed. Buckle up as we embark on a journey to unravel this mystery together, empowering you to add a unique feature into your next Flutter project. Let's dive in!

Code

Our journey through this Flutter adventure will be divided into three parts, each focusing on a fundamental aspect of our circular letter arrangement feature. Firstly, we'll embark on the quest to place the letters inside the circle with precision. Next, we are going to explore how to draw lines between the letters we connect and how these lines follow our finger across the circle until we meet the next letter. Finally, we will show the result of the word we created by connected these letters, at the top of the screen.

Crafting the Circular Arrangement

To start, we'll lay the foundation by creating a Container that will serve as the base for our feature. This container needs to be a perfect square so when we add the BoxDecoration, it transforms seamlessly into a circle. Next, we'll introduce a Stack widget, a versatile tool in Flutter's arsenal, to overlay our letters on top of the circular container. Finally, we 're ready to add the positions, based on the number of letters we try to place inside the circle.

Setup Constant Values

const double circleSize = 300;
const double boxSize = 50;

const Color primaryColor = Color.fromARGB(255, 166, 185, 204);
const Color highlightColor = Color.fromARGB(255, 95, 150, 187);
const Color textColor = Color.fromARGB(255, 26, 67, 94);
const Color whiteColor = Color(0xffFFFFFF);

Circle Container with Stack

final key = GlobalKey();
List<Widget> stackLetters = [];

Container(
    decoration: const BoxDecoration(
        shape: BoxShape.circle,
        color: primaryColor,
     ),
    width: circleSize, // same value with height
    height: circleSize, // same value with width
    child: Stack(
        children: [
            Stack(
                key: key,
                children: stackLetters,
            ),
        ],
    ),
),

The stackLetters, is a List of Widgets, but not any type of Widget. We need to create our own Box Widget, inside of which, every letter will be placed.

Create Letter Widget

This is how the letter will look like inside the circle.

class BoxDetails extends StatelessWidget {
  final String letter;
  final Alignment position;
  bool isSelected;

  BoxDetails({
    Key? key,
    required this.letter,
    required this.position,
    this.isSelected = false,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: position,
      child: Container(
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: isSelected ? textColor : highlightColor,
        ),
        alignment: Alignment.center,
        width: boxSize,
        height: boxSize,
        child: Text(
          letter,
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
            color: isSelected ? whiteColor : textColor,
          ),
        ),
      ),
    );
  }
}

But, it needs to be inside this LetterBox, so we can track the position.

class LetterBox extends SingleChildRenderObjectWidget {
  final int index;

  const LetterBox({required Widget child, required this.index, Key? key})
      : super(child: child, key: key);

  @override
  LetterBoxProxy createRenderObject(BuildContext context) {
    return LetterBoxProxy(index);
  }

  @override
  void updateRenderObject(BuildContext context, LetterBoxProxy renderObject) {
    renderObject.index = index;
  }
}

class LetterBoxProxy extends RenderProxyBox {
  int index;
  LetterBoxProxy(this.index);
}

As you can see, to achieve precise placement of each letter within the circular arrangement, we rely on the Align widget. By encapsulating every letter within an Align widget, we gain the ability to define its exact position within the circle. Take a good look at the images below, to better understand how the positioning works with and Align Widget and what we try to accomplish.

Double Container

When utilizing the Align widget to position elements within our circular arrangement, we're presented with a unique opportunity to leverage the widget's alignment parameters, which operate on a scale ranging from -1 to 1. Imagine the center of our circular container as the origin point (0, 0) of our coordinate system. As we move away from the center towards the right, the x-coordinate values increase, reaching a maximum value of 1 at the far-right edge of the container. Conversely, as we move towards the left, the x-coordinate values decrease, reaching a minimum value of -1 at the far-left edge. Therefore, when we specify the position of a letter using the Align widget, we don't directly reference pixel values such as x=300 (assuming 300 is the size of our circular container). Instead, we express the position relative to the container's dimensions using the range of values from -1 to 1.

Initialize Letters

To gain a deeper understanding of the positioning, let's explore a simple example with three letters.

// Positions

[
    [-0.8, 0.4],
    [0.8, 0.4],
    [0, -0.89],
]

The function generateLetters utilizes these positions to dynamically generate widgets representing each letter within the circular arrangement. The value of stackLetters that we use in the previous container, comes from this:

List<Widget> generateLetters({
  required String word,
  required List positions,
}) {
  return List<Widget>.generate(
    word.length,
    (index) => LetterBox(
      index: index,
      child: BoxDetails(
        letter: word[index],
        position: Alignment(
          positions[index][0],
          positions[index][1],
        ),
      ),
    ),
  );
}

And the result we get, looks like this:
Three Letters Screen

Connecting the Dots: Creating Lines

With the foundational elements of our circular arrangement in place, we now turn our attention to a more intricate challenge: connecting the letters together. How do we seamlessly link each letter to its neighbors, and how do we track the value of the LetterBox that we touch? Let's answer these questions.

Track Finger

First of all, we need to track whenever the user touch the circle container and then drags the finger around the circle. For this feature, we will use the Listener Widget:

Listener(
  onPointerMove: (event) {
    // Function for tap 
  },
  onPointerDown: (event) {
    // Function for drag 
  },
  onPointerUp: (event) {
    // Function for clearing the results
  },
  child: Container(
    decoration: const BoxDecoration(
      shape: BoxShape.circle,
      color: primaryColor,
    ),
    width: circleSize,
    height: circleSize,
    child: Stack(
      children: [
        Stack(
          key: key,
          children: stackLetters,
        ),
      ],
    ),
  ),
),

The function that we will use for onPointerMove and onPointerDown, is the same for both scenarios.

Custom Painter

Whenever we touch a letter inside the circle, we want to create a line that will start from the center of this letter, and it will follow our finger until we meet the next letter. Those lines, will look something like this:

class LinePainter extends CustomPainter {
  Offset? startPosition;
  Offset? endPosition;

  LinePainter({required this.startPosition, required this.endPosition});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = textColor
      ..strokeWidth = boxSize / 6;

    if (startPosition != null && endPosition != null) {
      canvas.drawLine(
        startPosition!,
        endPosition!,
        paint,
      );
    }
  }

  @override
  bool shouldRepaint(LinePainter oldDelegate) {
    return oldDelegate.startPosition != startPosition ||
        oldDelegate.endPosition != endPosition;
  }
}

Initializing all the lines is a straightforward process, but it's essential to remember that the number of lines is always one less than the number of letters. Lines and Letters

List<CustomPaint> generateLinePainter({
  required String word,
  required Offset? start,
  required Offset? end,
}) {
  return List<CustomPaint>.generate(
    word.length - 1,
    (index) => CustomPaint(
      painter: LinePainter(
        startPosition: start,
        endPosition: end,
      ),
    ),
  );
}

We give the return value of generateLinePainter to the variable lineConnections, and the new Circle Container looks like this:

Container(
  decoration: const BoxDecoration(
    shape: BoxShape.circle,
    color: primaryColor,
  ),
  width: circleSize,
  height: circleSize,
  child: Stack(
    children: [
      ...lineConnections, // this is where we add the lineConnections
      Stack(
        key: key,
        children: stackLetters,
      ),
    ],
  ),
),

Displaying the Lines and the Result

Now that we set everything, we are ready to implement the function that run onPointerMove and onPointerDown.

  String chosenLetters = "";

  Offset? startPosition;
  Offset? endPosition;
  int count = 0;

  Set<LetterBoxProxy> trackTaped = <LetterBoxProxy>{};
  List startingPositions = [];

  List positionList = [];
  
  void tapAndHoverLetter(PointerEvent event) {
  // Get the local position of where the user tapped inside the circle container
    final RenderBox box =
        key.currentContext!.findAncestorRenderObjectOfType<RenderBox>()!;
    final result = BoxHitTestResult();
    Offset local = box.globalToLocal(event.position);
    
    // Set the end position of the line, to the local position of the finger inside the circle
    setState(() {
      endPosition = local;

      if (count != 0 && count < widget.word.length) {
        lineConnections[count - 1] = CustomPaint(
          painter: LinePainter(
            startPosition: startingPositions[count - 1],
            endPosition: endPosition,
          ),
        );
      }
      if (count > 1 && count <= widget.word.length) {
        lineConnections[count - 2] = CustomPaint(
          painter: LinePainter(
            startPosition: startPosition,
            endPosition: startingPositions[count - 2],
          ),
        );
      }
    });

    // This is where we check if the tracking meets a letter
    if (box.hitTest(result, position: local)) {
      for (final hit in result.path) {
        final target = hit.target;

        if (target is LetterBoxProxy && !trackTaped.contains(target)) {
        // Setting the starting position with the center of the letter we tapped on
          setState(() {
            startPosition = findCenterOfBox(
              x: positionList[target.index][0],
              y: positionList[target.index][1],
            );

            // Changing the color of the LetterBox
            stackLetters[target.index] = LetterBox(
              index: target.index,
              child: BoxDetails(
                isSelected: true,
                letter: widget.word[target.index],
                position: Alignment(
                  positionList[target.index][0],
                  positionList[target.index][1],
                ),
              ),
            );
            
            // Updating the LinePainter List
            if (count < widget.word.length - 1) {
              lineConnections[count] = CustomPaint(
                painter: LinePainter(
                  startPosition: startPosition,
                  endPosition: endPosition,
                ),
              );
            }
            
            // Crafting the word
            chosenLetters += widget.word[target.index];

            startingPositions.add(startPosition);

            count++;
            trackTaped.add(target);
          });
        }
      }
    }
  }

Finally, when the user is done creating a word, and he moves the finger out of the Circle Container, we execute the onPointerUp from the Listener Widget, which calls the function below:

// Clear chosen letters
void clearLetters() {
    setState(() {
        count = 0;
        startPosition = null;
        endPosition = null;
        stackLetters = [];
        lineConnections = [];
        trackTaped = <LetterBoxProxy>{};
        startingPositions = [];
        chosenLetters = "";
    });
}

Double Screen

Conclusion

And there you have it! You've delved into the challenge of creating a circular arrangement of letters in Flutter, from precise positioning to dynamic line drawing. By following this guide, you've gained valuable insights into implementing a Word of Wonders letter container clone but also gained valuable insights into Flutter development techniques. I encourage you to head over to the Github Repository, to explore all the parts of the code. Feel free to reach out if you have any questions or need further guidance. Wish you a wonderful day and happy coding !!

If you enjoyed this article and want to stay connected, feel free to connect with me on LinkedIn.

If you'd like to dive deeper into the code and contribute to the project, visit the repository on GitHub.

Was this guide helpful? Consider buying me a coffee!☕️ Your contribution goes a long way in fuelling future content and projects. Buy Me a Coffee.

Affiliate Links

Check out what Thanasis Traitsis suggests for Connect Letters inside Circle Container in Flutter!

    No affiliates available for this post!

Ready to Forge an Alliance
🤝

Join our alliance and play a pivotal role in the evolution of our digital realm! By aligning with our pricing model, you're not just accessing premium features; you're becoming an integral part of our journey. As an ally, your support propels us forward, ensuring we continue to innovate and thrive.

Lifetime membership

As valued allies, exclusive content awaits you 👀. Unlock a suite of premium features and gain early access to ally-only enhancements in our realm. With our month-by-month commitment, you're always privy to our most coveted updates!

What's included

  • Premium Content Access
  • Ally resources + Unique Ally Badge
  • Access to Affiliate store front 🤑 (🔮)
  • More to come...

It's like buying a pint 🍺. But less!

€1.99 EUR

Forge Alliance

Invoices and receipts available for easy company reimbursement

Subscribe to our newsletter.

👋 Hey there, Realmer! Fancy getting a byte of nerdy knowledge straight to your inbox? Subscribe to our Sudorealm newsletter and don't miss a single trick from our growing community of curious minds! Ready to level up your knowledge game? Join us in the Realm today!

Be one of the privileged few
Think of it as your VIP pass into the Realm where you'll get first dibs on new features, insider updates, and more.
No spam
And, worry not, we promise not to spam – just top-tier, brain-tickling content.